Skip to content

ralph: defer per-Note Pandoc AST for #66 (-57% RSS)#742

Draft
srid wants to merge 1 commit into
masterfrom
ralph/memory-66-deferred
Draft

ralph: defer per-Note Pandoc AST for #66 (-57% RSS)#742
srid wants to merge 1 commit into
masterfrom
ralph/memory-66-deferred

Conversation

@srid

@srid srid commented May 17, 2026

Copy link
Copy Markdown
Owner

Independent of PR #740 — this is a fresh re-implementation of PR #678's approach (deferred per-Note Pandoc retention) without reading or rebasing its commits. Full design notes are in docs/dev/ralph/memory-66-deferred/README.md.

Result (5-run median, 4.5k synthetic corpus, +RTS -N)

Metric Baseline @6950760d This PR Δ
READY (s) 51 54 +6 %
LOAD_HWM 5165 MiB 1660 MiB −67.9 %
AFTER_HWM 5181 MiB 2219 MiB −57.2 %

How it works (short)

Note._noteDoc :: Pandoc is now a lazy field bound to a re-parse closure over _noteSourceText. First access on the render path forces the closure (pure markdown re-parse, no Lua); Haskell's normal memoisation writes the result back into the field. Three categories opt out and keep the eager Pandoc: Lua-filter notes, synthetic notes (folder placeholders / banners), and parse-error notes.

Two new pre-extracted summary fields land at parse time so the model never walks the AST during insertion:

  • _noteLinks :: ![NoteLink] for the relation index
  • _noteTaskList :: ![NoteTask] for the task index

A few surgical bits make the deferral actually free memory rather than just delay it:

  • mkNoteWith branches eagerly on retain-vs-defer, so each Note's _noteDoc references only one of {doc, deferredDoc}. An if mustRetain then doc else deferredDoc field value would keep both alive in a thunk closure.
  • parseAndInsert deepseq-forces only the two summary fields. _noteDoc is deliberately not forced — forcing it would immediately materialise the re-parse and undo the optimisation.
  • Eq / Ord on Note are now manual and compare by _noteRoute only. The derived versions would force _noteDoc on every IxSet insertion-time comparison.
  • modelLookupBacklinks recovers per-link context on demand via a new Rel.noteRelCtxToTarget.

Test plan

  • cabal test emanote passes (125 examples, 0 failures)
  • Real docs/ notebook renders correctly on the routes that work (the pre-existing /guide, /wikilinks, /yaml-config 500s reproduce on origin/master too — not introduced by this PR)
  • 5-run methodology measured and stable
  • e2e suite — not yet run locally; relying on CI

Known limitation

The synthetic corpus is intentionally pathological. A note that transitively reaches hundreds of others through ![[]] embeds pays one cold parse per unique reachable note on first render — /topic00/n00032 (19 direct embeds, dense fan-out) exceeds the 30 s curl timeout on a cold process. Subsequent renders are fast. On the real docs/ notebook this does not surface (RSS ~255 MiB, sub-100ms renders).

Mitigations (not implemented here): stripped-down summary Pandoc for embed bodies (PR #678's approach), request-scoped Pandoc cache plumbed through RenderCtx, or unsafePerformIO IORef per Note with explicit eviction.

Relationship to other PRs

Branch Approach Δ AFTER_HWM
baseline origin/master @6950760d 5181 MiB
#740 deepseq + -F1.5 + _relCtx drop 3672 MiB (−29 %)
this PR deferred _noteDoc 2219 MiB (−57 %)
#678 NoteBody sum type + summary Pandoc (existing) 752 MiB on a similar 4.5k corpus

PR #740 and this PR are independent and additive in spirit. PR #678 still wins on absolute numbers — its NoteBodyDeferred carries a stripped-down "summary doc" that bounds embed-render cost, which is the headroom this PR leaves unaddressed.

Refs #66.

Drop the eager Pandoc-AST retention in `Note._noteDoc`. The field is
now a lazy thunk bound to a pure markdown re-parse over
`_noteSourceText`; Haskell's normal lazy-evaluation semantics memoise
the parsed Pandoc back into the field on first access.

Lua-filter notes, synthetic notes (mkEmptyNoteWith), and error-carrying
notes still bind `_noteDoc` to the already-evaluated Pandoc because
their post-filter shape can't be reproduced by a pure re-parse.

Pre-extracted summary fields land at parse time so neither `noteRels`
nor `noteTasks` needs to walk the full AST:

- `_noteSourceText :: !Text`         -- raw source for re-parse
- `_noteLinks      :: ![NoteLink]`   -- url + attrs for relation index
- `_noteTaskList   :: ![NoteTask]`   -- checked + inlines for task index

`parseAndInsert` deepseq-forces the two summary fields at insert time
(but not `_noteDoc` itself — forcing it would re-parse immediately,
defeating the purpose). `mkNoteWith` branches eagerly on the
retain/defer decision so each Note's `_noteDoc` references only one
of `{doc, deferredDoc}`, never both via an if-thunk closure.

`Eq` / `Ord` on `Note` are now manual and compare by `_noteRoute`
alone — derived versions would force `_noteDoc` on every IxSet
insertion-time comparison.

`modelLookupBacklinks` re-extracts per-link context on demand via a
new `Rel.noteRelCtxToTarget`, since `_relCtx` is no longer carried
inside `Rel`.

5-run median peak RSS on the 4.5k synthetic corpus:

| Metric        | Baseline | This PR  | Δ |
| ------------- | -------: | -------: | --- |
| LOAD_HWM      | 5165 MiB | 1660 MiB | -67.9 % |
| AFTER_HWM     | 5181 MiB | 2219 MiB | -57.2 % |

Tests pass (125 examples, 0 failures).

Known limitation: dense-embed render latency on the synthetic corpus.
A hub note that transitively reaches hundreds of others through `![[]]`
embeds pays one cold parse per unique reachable note on first render.
The real `docs/` notebook does not surface this (RSS ~255 MiB, sub-100ms
renders).

Refs #66.
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