Skip to content

Return zero-copy views from large slice/substring/substr results#2506

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:string-slice-views
Jun 8, 2026
Merged

Return zero-copy views from large slice/substring/substr results#2506
lahma merged 1 commit into
sebastienros:mainfrom
lahma:string-slice-views

Conversation

@lahma

@lahma lahma commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

String.prototype.slice/substring/substr allocated a full string.Substring copy for every result. MemoryProbe attributed 89.9% + 9.2% of dromaeo-object-string''s 1.26 GB/op to exactly this.

What

The three methods now route through JsString.CreateSliced:

  • whole-string results share the receiver / backing string outright (no copy, no wrapper for slice(0) on a JsString receiver);
  • large results covering ≥ half of the source (and ≥ 512 chars) become SlicedString — an immutable lazy view (offset + length) over the backing string, nested beside ConcatenatedString. It materializes only on ToString() (cached), overrides indexer/Length/equality span-based (no materialization for length/charCodeAt/comparisons), hashes identically to the equivalent flat string, and — being immutable — never requires cloning, so it can be stored into bindings/properties without flattening;
  • small or low-coverage results keep copying, so a short-lived view can never pin a much larger backing string (bounded retention: a view covers ≥ ½ of its source by construction).

TypeReference read JsString._value directly (the only such site outside JsString.cs) and now uses ToString().

Measurements (new StringSliceBenchmarks, same-window baselines, 128K char source)

Benchmark Before After Alloc
SliceLargeDiscard 140.0 ms 1.51 ms (−98.9%) 1.14 GB → 394 KB
SubstringLargeDiscard 143.9 ms 1.89 ms (−98.7%) 1.14 GB → 1.0 MB
SliceThenRead 125.5 ms 1.62 ms reads don''t materialize
SliceSmall (guard) 2.18 ms 2.19 ms identical

EngineComparison dromaeo-object-string: wall-clock −53% (175.1 → 81.8 ms/iter), per-op allocations 1,272 → 136 MB (−89%) — the remaining MBs are the intentional retention-guard copies. base64/regexp guards unchanged.

Verification

  • Jint.Tests 0 failures (net10.0 + net472), PublicInterface green
  • Test262: 99,208 passed / 0 failed

🤖 Generated with Claude Code

@lahma
lahma force-pushed the string-slice-views branch from f3c41ac to 134004b Compare June 7, 2026 17:53
lahma added a commit to lahma/jint that referenced this pull request Jun 7, 2026
Jint rows now reflect the empty-FDI, string-slice-view and
global-binding-cache changes (PRs sebastienros#2502, sebastienros#2506, sebastienros#2507): object-string
~61 ms / 139 MB (was ~170 ms / 1.3 GB), stopwatch ~180 ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
String.prototype.slice/substring/substr now route through
JsString.CreateSliced: whole-string results share the receiver/backing
string outright, and large results covering at least half of the source
(and >= 512 chars) become SlicedString - an immutable lazy view over the
backing string (offset + length) that materializes only on ToString().
Small or low-coverage results keep copying so a short-lived view can
never pin a much larger backing string. SlicedString never requires
cloning (immutable), overrides indexer/Length/equality/hashing to operate
span-based without materializing, and hashes identically to the
equivalent flat string. TypeReference read JsString._value directly and
now uses ToString().

MemoryProbe had attributed 89.9% + 9.2% of dromaeo-object-string''s
1.26 GB/op to slice/substring full copies.

Gating (new StringSliceBenchmarks, same-window baselines):
- SliceLargeDiscard 140.0ms -> 1.51ms (-98.9%), alloc 1.14GB -> 394KB
- SubstringLargeDiscard 143.9ms -> 1.89ms (-98.7%), alloc 1.14GB -> 1.0MB
- SliceThenRead 125.5ms -> 1.62ms (length/charCodeAt reads do not materialize)
- SliceSmall guard unchanged (2.18 -> 2.19ms, alloc identical)
- EngineComparison dromaeo-object-string wall-clock -53% (175.1 -> 81.8 ms/iter);
  per-op allocations 1,272MB -> 136MB (-89%); remaining substring copies are
  the intentional retention guard (12000-char result of a 131K source)
- base64/regexp guards within driver noise
- Jint.Tests 0 failures (net10.0 + net472), PublicInterface green,
  Test262 99208 passed / 0 failed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the string-slice-views branch from 134004b to b783882 Compare June 8, 2026 13:21
@lahma
lahma merged commit e19192f into sebastienros:main Jun 8, 2026
4 checks passed
lahma added a commit to lahma/jint that referenced this pull request Jun 8, 2026
Fresh full EngineComparison run on main with all five engine-comparison
perf PRs merged (sebastienros#2502 empty-FDI, sebastienros#2503 eval cache, sebastienros#2504 prepared-script
fix, sebastienros#2506 string slice views, sebastienros#2507 global-binding cache). Jint now:
- dromaeo-object-string ~58-61 ms / 139 MB (rank 1, was ~170 ms / 1.3 GB
  and losing to NiL.JS) and the -modern variant likewise rank 1
- array-stress rank 1 (3,290 us, ParsedScript)
- stopwatch 174-181 ms (was ~205 ms)
- still the lowest allocations in nearly every workload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Jun 8, 2026
…2505)

* Refresh engine comparison results; add wall-clock profiling driver

README.md: 2026-06-07 results on .NET 10.0.8 (Jurassic 3.2.9,
NiL.JS 2.6.1722, YantraJS.Core 1.2.404 - all latest). Notes the severe
YantraJS 1.2.404 regression on dromaeo-object-regexp (~35 s/op, ~32 GB
allocated; 1.2.344 ran it at ~1 s/op).

CpuProfileDriver adds a `--profile-cpu <script> <iters> <source|prepared>`
mode mirroring EngineComparisonBenchmark per-op shape (fresh strict engine
per iteration, dromaeo helper stubs) for quick wall-clock A/B comparisons,
complementing the existing --profile-memory probe.

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

* Add DateConstructionBenchmarks guard

Records a falsified optimization hypothesis: skipping the redundant
TimeClip + double round-trip in the `new Date()` now-path showed no
measurable win (15.15ms +/- 0.38 -> 15.19ms +/- 0.21 per 100k
constructions) because per-construction cost is dominated by dispatch,
allocation and the UtcNow syscall. Kept as a guard for future Date work.

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

* Update results table with run against fully-merged main

Fresh full EngineComparison run on main with all five engine-comparison
perf PRs merged (#2502 empty-FDI, #2503 eval cache, #2504 prepared-script
fix, #2506 string slice views, #2507 global-binding cache). Jint now:
- dromaeo-object-string ~58-61 ms / 139 MB (rank 1, was ~170 ms / 1.3 GB
  and losing to NiL.JS) and the -modern variant likewise rank 1
- array-stress rank 1 (3,290 us, ParsedScript)
- stopwatch 174-181 ms (was ~205 ms)
- still the lowest allocations in nearly every workload

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma
lahma deleted the string-slice-views branch July 13, 2026 06:52
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