@zio.dev/zio-blocks 0.0.51 → 0.0.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adr/2026-07-18-data-migration.md +123 -0
- package/guides/async-getting-started.md +687 -0
- package/guides/compile-time-resource-safety-with-scope.md +6 -0
- package/guides/getting-started-with-mux.md +0 -112
- package/guides/query-dsl-extending.md +1 -1
- package/guides/query-dsl-fluent-builder.md +1 -1
- package/guides/query-dsl-reified-optics.md +1 -1
- package/guides/query-dsl-sql.md +395 -1
- package/guides/sql-checked-interpolation.md +173 -0
- package/guides/sql-transactions.md +286 -0
- package/guides/telemetry-guide.md +131 -70
- package/guides/zio-schema-migration.md +6 -6
- package/index.md +200 -583
- package/package.json +1 -1
- package/reference/async.md +1379 -531
- package/reference/chunk.md +3 -3
- package/reference/codegen/index.md +1 -1
- package/reference/combinators.md +4 -4
- package/reference/config/config-decoder.md +460 -0
- package/reference/config/config-source.md +489 -0
- package/reference/config/errors.md +278 -0
- package/reference/config/flags.md +369 -0
- package/reference/config/formats.md +314 -0
- package/reference/config/index.md +304 -0
- package/reference/config/rollout.md +336 -0
- package/reference/context.md +6 -49
- package/reference/data-migration.md +269 -0
- package/reference/datastar/attributes.md +302 -0
- package/reference/datastar/events.md +234 -0
- package/reference/datastar/index.md +256 -0
- package/reference/datastar/signals.md +230 -0
- package/reference/datastar/sse.md +295 -0
- package/reference/datastar.md +2 -2
- package/reference/docs.md +2 -2
- package/reference/endpoint/bulk-creation.md +96 -0
- package/reference/endpoint/index.md +9 -89
- package/reference/endpoint/path-codec.md +12 -24
- package/reference/endpoint/route-pattern.md +4 -6
- package/reference/endpoint/segment-codec.md +19 -32
- package/reference/html.md +313 -9
- package/reference/htmx/index.md +4 -52
- package/reference/htmx/response-headers.md +240 -0
- package/reference/http-model/headers.md +735 -0
- package/reference/http-model/index.md +3 -1
- package/reference/http-model/model.md +107 -71
- package/reference/http-model/schema-codecs.md +522 -0
- package/reference/http-model/schema.md +6 -3
- package/reference/http-model/server-sent-event.md +341 -0
- package/reference/jwt.md +195 -0
- package/reference/maybe.md +128 -11
- package/reference/media-type.md +2 -2
- package/reference/mux.md +254 -0
- package/reference/mux.mdx +7 -2
- package/reference/openapi.md +3 -3
- package/reference/projection.md +654 -0
- package/reference/resource-management/resource.md +2 -98
- package/reference/resource-management/scope.md +1 -209
- package/reference/resource-management/wire.md +4 -50
- package/reference/ringbuffer/advanced.mdx +1 -1
- package/reference/ringbuffer/index.mdx +3 -3
- package/reference/ringbuffer/mpmc.mdx +38 -4
- package/reference/ringbuffer/mpsc.mdx +36 -4
- package/reference/ringbuffer/spmc.mdx +1 -1
- package/reference/ringbuffer/spsc.mdx +87 -15
- package/reference/schema/allows.md +0 -96
- package/reference/schema/binding.md +2 -2
- package/reference/schema/built-in-codecs/avro.md +2 -2
- package/reference/schema/built-in-codecs/bson.md +50 -20
- package/reference/schema/built-in-codecs/csv.md +2 -2
- package/reference/schema/built-in-codecs/index.md +3 -3
- package/reference/schema/built-in-codecs/json/index.md +2 -2
- package/reference/schema/built-in-codecs/messagepack.md +3 -3
- package/reference/schema/built-in-codecs/thrift.md +2 -2
- package/reference/schema/built-in-codecs/toon.md +3 -3
- package/reference/schema/built-in-codecs/yaml.md +2 -2
- package/reference/schema/codec.md +11 -11
- package/reference/schema/dynamic-optic.md +48 -3
- package/reference/schema/dynamic-schema.md +3 -3
- package/reference/schema/index.md +2 -0
- package/reference/schema/path-interpolator.md +2 -0
- package/reference/schema/reflect-transformer.md +140 -0
- package/reference/schema/schema-evolution/as.md +4 -4
- package/reference/schema/schema-evolution/into.md +2 -2
- package/reference/schema/schema-expr.md +2 -2
- package/reference/schema/schema-search.md +263 -0
- package/reference/schema/schema.md +10 -2
- package/reference/schema/type-class-derivation.md +1 -1
- package/reference/smithy.md +502 -3
- package/reference/sql/db-codec-deriver.md +3 -3
- package/reference/sql/db-codec.md +22 -22
- package/reference/sql/db-con.md +4 -4
- package/reference/sql/db-connection.md +1 -1
- package/reference/sql/db-param.md +1 -1
- package/reference/sql/db-result-reader.md +4 -2
- package/reference/sql/db-tx.md +46 -14
- package/reference/sql/ddl.md +1 -1
- package/reference/sql/frag.md +44 -10
- package/reference/sql/index.md +7 -7
- package/reference/sql/repo.md +15 -15
- package/reference/sql/sql-dialect.md +1 -1
- package/reference/sql/sql-logger.md +1 -1
- package/reference/sql/sql-name-mapper.md +3 -3
- package/reference/sql/table-metadata.md +3 -3
- package/reference/sql/table.md +10 -10
- package/reference/sql/transactor-zio.md +1 -1
- package/reference/sql/transactor.md +21 -11
- package/reference/sql-zio.md +1 -1
- package/reference/streams/core/index.md +32 -0
- package/reference/streams/{pipeline.md → core/pipeline.md} +210 -74
- package/reference/streams/{sink.md → core/sink.md} +331 -353
- package/reference/streams/{stream.md → core/stream.md} +919 -209
- package/reference/streams/execution-and-compatibility/async-execution.md +822 -0
- package/reference/streams/execution-and-compatibility/index.md +35 -0
- package/reference/streams/execution-and-compatibility/platform-differences.md +297 -0
- package/reference/streams/execution-and-compatibility/scala-2-compatibility.md +88 -0
- package/reference/streams/execution-and-compatibility/zero-boxing.md +393 -0
- package/reference/streams/index.md +140 -67
- package/reference/streams/primitives/index.md +30 -0
- package/reference/streams/primitives/reader.md +1992 -0
- package/reference/streams/{writer.md → primitives/writer.md} +254 -98
- package/reference/telemetry/common/any-value.md +90 -0
- package/reference/telemetry/common/attribute-key.md +87 -0
- package/reference/telemetry/common/attributes.md +118 -0
- package/reference/telemetry/common/index.md +39 -0
- package/reference/telemetry/common/instrumentation-scope.md +24 -0
- package/reference/telemetry/common/resource.md +34 -0
- package/reference/telemetry/index.md +311 -0
- package/reference/telemetry/logging/index.md +197 -0
- package/reference/telemetry/logging/log-enrichment.md +72 -0
- package/reference/telemetry/logging/log-formatter.md +100 -0
- package/reference/telemetry/logging/log-record-processor.md +56 -0
- package/reference/telemetry/logging/log-record.md +44 -0
- package/reference/telemetry/logging/log-writer.md +64 -0
- package/reference/telemetry/logging/logger-provider.md +142 -0
- package/reference/telemetry/logging/logger.md +83 -0
- package/reference/telemetry/logging/severity.md +62 -0
- package/reference/telemetry/metrics/index.md +150 -0
- package/reference/telemetry/metrics/instruments.md +183 -0
- package/reference/telemetry/metrics/labeled-instruments.md +74 -0
- package/reference/telemetry/metrics/meter-provider.md +76 -0
- package/reference/telemetry/metrics/meter.md +98 -0
- package/reference/telemetry/metrics/metric-data.md +57 -0
- package/reference/telemetry/otel/custom-exporter.md +216 -0
- package/reference/telemetry/otel/index.md +212 -0
- package/reference/telemetry/tracing/index.md +155 -0
- package/reference/telemetry/tracing/sampler.md +89 -0
- package/reference/telemetry/tracing/span-builder.md +57 -0
- package/reference/telemetry/tracing/span-context.md +39 -0
- package/reference/telemetry/tracing/span-data.md +32 -0
- package/reference/telemetry/tracing/span-kind.md +55 -0
- package/reference/telemetry/tracing/span-processor.md +53 -0
- package/reference/telemetry/tracing/span-status.md +47 -0
- package/reference/telemetry/tracing/span.md +117 -0
- package/reference/telemetry/tracing/tracer-provider.md +91 -0
- package/reference/telemetry/tracing/tracer.md +52 -0
- package/reference/typeid.md +0 -64
- package/sidebars.js +150 -12
- package/undocumented-report.md +528 -270
- package/reference/config.md +0 -158
- package/reference/streams/concurrent-operators.md +0 -106
- package/reference/streams/reader.md +0 -1284
- package/reference/streams/scala-2-compatibility.md +0 -55
- package/reference/streams/zero-boxing.md +0 -275
- package/reference/telemetry.md +0 -693
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: zero-boxing
|
|
3
|
+
title: "Zero-Boxing Optimization"
|
|
4
|
+
sidebar_label: "Zero-Boxing"
|
|
5
|
+
description: "How ZIO Blocks Streams picks a primitive lane, signals end of stream on each one, and where boxing still happens."
|
|
6
|
+
keywords:
|
|
7
|
+
- "Primitive Specialization"
|
|
8
|
+
- "Zero Boxing"
|
|
9
|
+
- "Lane Selection"
|
|
10
|
+
- "End Of Stream Signalling"
|
|
11
|
+
- "JvmType"
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Working with streams of primitives presents a performance challenge in languages with generic types: **boxing**. Without special care, primitive values get wrapped in objects. ZIO Blocks Streams therefore carries runtime element-type information and provides primitive physical lanes for synchronous interpretation. This removes boxing at important boundaries and hot paths; it is not a promise that every stream program, callback, terminal, or asynchronous operation is allocation-free.
|
|
15
|
+
|
|
16
|
+
## The Boxing Problem
|
|
17
|
+
|
|
18
|
+
In Scala, primitive types (`Int`, `Long`, `Double`, `Boolean`) are fundamentally different from their object counterparts (`Integer`, `Long`, `Double`, `Boolean`). When a generic class like `Stream[E, A]` works with primitives, the compiler must box them into objects to satisfy the generic contract:
|
|
19
|
+
|
|
20
|
+
```scala
|
|
21
|
+
// Without optimization, this boxes each Int into an Integer object
|
|
22
|
+
val stream: Stream[Nothing, Int] = Stream(1, 2, 3, 4, 5)
|
|
23
|
+
val doubled = stream.map(_ * 2) // Each Int is boxed → Integer → boxed result
|
|
24
|
+
val result = doubled.runCollect
|
|
25
|
+
// Result: Each element was boxed, unboxed, boxed again — wasteful!
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Performance cost:**
|
|
29
|
+
- Extra heap allocations (memory pressure, more GC)
|
|
30
|
+
- Cache misses (objects spread across memory)
|
|
31
|
+
- Slower CPU operations (dereferencing objects instead of primitive registers)
|
|
32
|
+
|
|
33
|
+
For high-throughput data processing, this overhead is unacceptable.
|
|
34
|
+
|
|
35
|
+
## ZIO Blocks Streams' Solution: JvmType Dispatch
|
|
36
|
+
|
|
37
|
+
Instead of using Scala's `@specialized` annotation (which generates separate classes for each primitive type, bloating binaries), ZIO Blocks Streams uses **compile-time type detection + runtime dispatch**. This gives you the speed of specialization without the binary bloat.
|
|
38
|
+
|
|
39
|
+
### How It Works
|
|
40
|
+
|
|
41
|
+
**Step 1: Compile-Time Detection**
|
|
42
|
+
|
|
43
|
+
When you create a stream of primitives, the compiler infers a `JvmType` implicit that identifies the element type:
|
|
44
|
+
|
|
45
|
+
```scala
|
|
46
|
+
import zio.blocks.streams.*
|
|
47
|
+
|
|
48
|
+
val intStream: Stream[Nothing, Int] = Stream(1, 2, 3)
|
|
49
|
+
// Compiler infers: JvmType.Infer[Int]
|
|
50
|
+
// The stream records its element representation
|
|
51
|
+
|
|
52
|
+
val doubled = intStream.map(_ * 2)
|
|
53
|
+
// map receives JvmType.Infer evidence for its transformed result type.
|
|
54
|
+
// Its input representation comes from intStream, not call-site evidence.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Step 2: Runtime Type Dispatch**
|
|
58
|
+
|
|
59
|
+
Stream and pipeline nodes retain their logical `JvmType`. When a run materializes a reader, the drain loop reads that reader's own `jvmType` tag and branches once onto the matching primitive pull. The tag describes what the reader physically pulls, not what the static type was widened to, so a `Stream[Nothing, Int]` widened to `Stream[Nothing, AnyVal]` and then filtered is still pulled through the `Int` lane. [When Zero-Boxing Applies](#when-zero-boxing-applies) lists the lanes and [How a lane is chosen](#how-a-lane-is-chosen) covers when each half of the decision is made.
|
|
60
|
+
|
|
61
|
+
**Step 3: Unboxed Accessors**
|
|
62
|
+
|
|
63
|
+
Instead of a single `read()` method that returns boxed `Any` (where boxed means wrapping primitives in object wrappers like `Integer`, `Long`, `Double`), primitives use specialized accessors that operate directly on primitive values:
|
|
64
|
+
|
|
65
|
+
```scala
|
|
66
|
+
abstract class Reader[+Elem] {
|
|
67
|
+
def jvmType: JvmType = JvmType.AnyRef
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
object Reader {
|
|
71
|
+
abstract class SyncReader[+Elem] extends Reader[Elem] {
|
|
72
|
+
def read[A >: Elem](sentinel: A): A
|
|
73
|
+
def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Int
|
|
74
|
+
def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Long
|
|
75
|
+
def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Double
|
|
76
|
+
def readDoubles(buf: Array[Double], offset: Int, maxLen: Int)(implicit ev: Elem <:< Double): Int
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
abstract class AsyncReader[+Elem] extends Reader[Elem] with AsyncReaderPlatform[Elem] {
|
|
80
|
+
def read[A >: Elem](sentinel: A): Async[A]
|
|
81
|
+
def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Async[Int]
|
|
82
|
+
def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Async[Long]
|
|
83
|
+
def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Async[Double]
|
|
84
|
+
def readDoubles(dest: Array[Double], offset: Int, length: Int)(implicit ev: Elem <:< Double): Async[Int]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The complete primitive pull surface distinguishes all eight identities: `Boolean`, `Byte`, `Short`, and `Char` use `Int` carriers; `Int` uses a widened `Long`; `Long` uses `Long`; `Float` uses a widened `Double`; and `Double` uses `Double`. The scalar reads name their sentinel parameter `_sentinel` because the base implementations spend it only on the reference path and reject it on a genuine primitive lane, which primitive readers override. `Long` and `Double` never spend it at all — those two lanes detect end of stream through the bulk reads instead, which is why `readDoubles` appears above beside its scalar twin. [EOF signalling per lane](#eof-signalling-per-lane) has the full table.
|
|
90
|
+
|
|
91
|
+
For `SyncReader`, the carrier is a JVM primitive at the method boundary. `SyncInterpreter` also stores primitive state in `Long`-backed physical arrays (using raw bits where necessary) and invokes lane-adapted operators. By contrast, `AsyncReader` returns generic `Async[T]` values. `AsyncInterpreter` keeps primitive lane state internally, but completed primitive results cross the generic async carrier and may be boxed — see [Async and boxing](#async-and-boxing).
|
|
92
|
+
|
|
93
|
+
## Practical Benefits
|
|
94
|
+
|
|
95
|
+
To understand the intended benefit, consider how boxing can accumulate through a pipeline. Compare a hypothetical boxed implementation with ZIO Streams' primitive-lane approach.
|
|
96
|
+
|
|
97
|
+
### Before (Hypothetical Boxed Streams)
|
|
98
|
+
|
|
99
|
+
Without optimization, each operation in a pipeline adds boxing overhead:
|
|
100
|
+
|
|
101
|
+
```scala
|
|
102
|
+
val nums = Stream(1, 2, 3, 4, 5)
|
|
103
|
+
val result = nums
|
|
104
|
+
.map(_ * 2) // boxes each Int → Integer, applies *, unboxes result
|
|
105
|
+
.filter(_ > 5) // boxes again, compares, unboxes
|
|
106
|
+
.map(_ + 1) // boxes, adds, unboxes
|
|
107
|
+
.runCollect
|
|
108
|
+
// 5 elements × 3 operations × boxing overhead = significant waste
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Memory profile:** Each element is boxed/unboxed multiple times, creating temporary objects.
|
|
112
|
+
|
|
113
|
+
### With ZIO Streams (Primitive Lanes)
|
|
114
|
+
|
|
115
|
+
The synchronous interpreter can keep the same pipeline on primitive physical lanes:
|
|
116
|
+
|
|
117
|
+
```scala
|
|
118
|
+
import zio.blocks.streams.*
|
|
119
|
+
|
|
120
|
+
val nums = Stream(1, 2, 3, 4, 5)
|
|
121
|
+
val result = nums
|
|
122
|
+
.map(_ * 2)
|
|
123
|
+
.filter(_ > 5)
|
|
124
|
+
.map(_ + 1)
|
|
125
|
+
.runCollect
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This design avoids per-stage primitive wrapper storage in the fused synchronous interpreter. It does **not** prove that the whole expression allocates nothing: generic Scala function interfaces, source or terminal construction, result collection, fallback paths, and JIT decisions can still introduce boxing or allocation. `runCollect` above is a JVM-only terminal; in cross-platform source write `runCollectAsync`, which drives the same primitive lanes and returns `Async[Either[E, Chunk[Int]]]`.
|
|
129
|
+
|
|
130
|
+
## When Zero-Boxing Applies
|
|
131
|
+
|
|
132
|
+
There are nine logical lanes: the eight JVM primitives — `Boolean`, `Byte`, `Char`, `Short`, `Int`, `Long`, `Float`, `Double` — and `AnyRef` for everything else. Each is a separate *physical pull identity*, because each has its own exact read method and its own end-of-stream convention. `readInt` is not an acceptable substitute for `Boolean`, `Byte`, `Char`, or `Short`, and a generic `read` followed by a cast is never an acceptable primitive pull.
|
|
133
|
+
|
|
134
|
+
Nine identities do not mean nine interpreter arrays. The interpreter compacts them into five storage lanes — int-like, `Long`, `Float`, `Double`, and reference — where the five small integral types (`Boolean`, `Byte`, `Char`, `Short`, `Int`) all land in the int-like lane. That compaction is visible in the arithmetic of the operator tags: a fused `map` is tagged `inLane * 5 + outLane`, twenty-five crossings rather than eighty-one. The read tag stays deliberately independent of the storage lane, so a `Short` stream is pulled by `readShort` and only afterwards shares int-like storage with an `Int` stream.
|
|
135
|
+
|
|
136
|
+
Primitive type inference and lane selection are automatic. Primitive streams are eligible for specialized synchronous paths, subject to the operation, source, terminal, and any fallback boundaries:
|
|
137
|
+
|
|
138
|
+
```scala
|
|
139
|
+
import zio.blocks.streams.*
|
|
140
|
+
|
|
141
|
+
// Primitive-specialized logical types
|
|
142
|
+
val ints = Stream(1, 2, 3).map(_ * 2)
|
|
143
|
+
val longs = Stream(1L, 2L, 3L).filter(_ > 0L)
|
|
144
|
+
val doubles = Stream(1.5, 2.5, 3.5).map(_ + 1.0)
|
|
145
|
+
val bools = Stream(true, false, true).filter(identity)
|
|
146
|
+
|
|
147
|
+
// Reference lane: fields are primitive, but Point itself is an object
|
|
148
|
+
case class Point(x: Int, y: Int)
|
|
149
|
+
val points = Stream(Point(1, 2), Point(3, 4))
|
|
150
|
+
.map(p => Point(p.x * 2, p.y * 2))
|
|
151
|
+
|
|
152
|
+
// Reference lane: tuples are objects
|
|
153
|
+
val pairs = Stream((1, 2), (3, 4))
|
|
154
|
+
.map { case (x, y) => (x + 1, y + 1) }
|
|
155
|
+
|
|
156
|
+
// Works, but may box for non-primitive types
|
|
157
|
+
val strings = Stream("a", "b", "c").map(_.toUpperCase)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
You don't need to do anything special — the compiler and runtime handle it automatically.
|
|
161
|
+
|
|
162
|
+
## How a Lane Is Chosen
|
|
163
|
+
|
|
164
|
+
The decision is hybrid and asymmetric: half of it is made when your code compiles, half when the stream runs.
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
168
|
+
│ COMPILE TIME — output lane │
|
|
169
|
+
│ JvmType.Infer[B] resolves where the result of map/collect │
|
|
170
|
+
│ lands. Nothing is generated: no @specialized, no macros. │
|
|
171
|
+
└────────────────────────────────────────────────────────────────┘
|
|
172
|
+
|
|
173
|
+
the node stores that tag and is then complete
|
|
174
|
+
▼
|
|
175
|
+
|
|
176
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
177
|
+
│ RUNTIME, ONCE PER DRAIN — input lane │
|
|
178
|
+
│ reader.jvmType names the lane the materialized reader │
|
|
179
|
+
│ actually pulls on, whatever the widened static type says. │
|
|
180
|
+
└────────────────────────────────────────────────────────────────┘
|
|
181
|
+
|
|
182
|
+
one match picks the loop, then the loop runs
|
|
183
|
+
▼
|
|
184
|
+
|
|
185
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
186
|
+
│ PER ELEMENT — no dispatch left to do │
|
|
187
|
+
│ The chosen loop calls one exact pull and one callback. │
|
|
188
|
+
│ The lane is never re-decided while elements flow. │
|
|
189
|
+
└────────────────────────────────────────────────────────────────┘
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**The output lane is fixed at compile time.** An operation that can change the element representation asks for evidence about what it produces, never about what it consumes:
|
|
193
|
+
|
|
194
|
+
```scala
|
|
195
|
+
def map[B](f: A => B)(implicit jtB: JvmType.Infer[B]): Stream[E, B]
|
|
196
|
+
def filter(pred: A => Boolean): Stream[E, A]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
One implicit, and it is about `B`. Implicit resolution finds one of the eight primitive instances or falls through to `Infer.boxed[A]` and records `AnyRef`; adding a `JvmType.Infer` parameter to a signature is always source-compatible, because the low-priority fallback never fails to resolve. Lane-preserving operations such as `filter` ask for nothing and keep whatever representation they were handed.
|
|
200
|
+
|
|
201
|
+
`Infer` is invariant in `A`. Contravariance would let primitive evidence satisfy `Infer[Nothing]` by accident and let fallback evidence drag a precise output toward `Any`; invariance plus an explicit `Infer[Nothing]` instance closes both. The fallback itself lives in per-Scala-version `LowPriorityJvmTypeInferPlatform` files whose Scala 2 and Scala 3 bodies are byte-identical, so the split is directory-only and there is no version-dependent inference behavior to reason about.
|
|
202
|
+
|
|
203
|
+
**The input lane is chosen at runtime, from the reader.** A `Stream` only describes; a run materializes a `Reader`, and that reader reports the lane it physically pulls on through `jvmType`. This is a contract about the reader, not about the static type: widening `Stream[Nothing, Int]` to `Stream[Nothing, AnyVal]` and then filtering, tapping, taking, or buffering still pulls the source through the `Int` lane, because none of those operations changes the representation.
|
|
204
|
+
|
|
205
|
+
**Dispatch happens once per drain, never per element.** A synchronous fold matches on `reader.jvmType` one time, enters the loop belonging to that lane, and stays in it; the asynchronous puller goes further and resolves its per-element pull function once, when it is constructed. Inside the loop no lane test remains — one exact pull, one callback.
|
|
206
|
+
|
|
207
|
+
### There Is No Lane Diagnostic for a Stream
|
|
208
|
+
|
|
209
|
+
Readers reach this section wanting a way to confirm that a pipeline stayed on a primitive lane. Nothing on `Stream` answers that, and it is worth being explicit about why each apparent candidate is not it either.
|
|
210
|
+
|
|
211
|
+
- `JvmType.Infer[A]` reports the **static** type, which is exactly the thing the representation machinery stopped trusting. A `Stream[Nothing, AnyVal]` whose reader is still on the `Int` lane infers `AnyRef`.
|
|
212
|
+
- `ElementRepresentation` — the three-case value (`Known(jvmType)`, `Boxed`, `LateBound`) that stream nodes actually carry — is `private[streams]` and not part of the public API.
|
|
213
|
+
- `Reader#jvmType` **is** public, so inside a `Sink.createBoth` synchronous callback, or after `Stream#startAsync` or `Stream#useReaderAsync`, you can read the materialized reader's lane. That answers a narrower question than the one being asked: it names the lane at that one boundary, not whether every fused stage kept it.
|
|
214
|
+
|
|
215
|
+
For the end-to-end question, allocation profiling is the real diagnostic. Measure the workload under an allocation profiler and read the normalized allocation rate; a throughput number on its own cannot tell you whether a lane was held.
|
|
216
|
+
|
|
217
|
+
## Generalized Specialization
|
|
218
|
+
|
|
219
|
+
All eight primitive lanes are specialized — `Boolean`, `Byte`, `Char`, `Short`, `Int`, `Long`, `Float`, and `Double` — across `Stream`, `Sink`, `Pipeline`, and `Reader`, in both the synchronous and the asynchronous interpreter, on the JVM and on Scala.js. Counting the reference fallback, that is nine-lane dispatch.
|
|
220
|
+
|
|
221
|
+
The specialized path is not reserved for any one element and accumulator pairing. A `Short` stream folded into a `Double`, a `Char` stream merged across workers, a `Boolean` stream collected — each reaches the same shared drivers, the same selector lifecycle, and the same buffer policy as an `Int` stream reduced by a `Long` checksum.
|
|
222
|
+
|
|
223
|
+
Specialization is about where boxing is avoided by construction, not a throughput promise: it says which lane a pull travels on, not what a given workload will measure.
|
|
224
|
+
|
|
225
|
+
## EOF Signalling Per Lane
|
|
226
|
+
|
|
227
|
+
A pull has to be able to say "there are no more elements" without allocating an `Option` to say it in, so every lane reports exhaustion out of band. The schemes differ because the lanes differ. The authority is the nine-branch dispatch in `Sink.foldSyncReader`; the library's other synchronous drain loops — `Reader#readAll`, `Sink.exists`, `Sink.find` — use the same scheme for each lane. The asynchronous puller differs on two lanes: it detects end of stream on `Byte` and `Float` by a negative count from `readBytes` and `readFloats`, rather than by the scalar sentinel.
|
|
228
|
+
|
|
229
|
+
| Lane | Public pull for this lane | Carrier | End of stream |
|
|
230
|
+
|-----------|------------------------------|------------------|-------------------------------------|
|
|
231
|
+
| `Boolean` | `readBoolean(-1)` | `Int` | any negative value |
|
|
232
|
+
| `Byte` | `readByte()` | `Int` | `-1` (any negative value); takes no sentinel parameter |
|
|
233
|
+
| `Char` | `readChar(-1)` | `Int` | any negative value |
|
|
234
|
+
| `Short` | `readShort(Int.MinValue)` | `Int` | `Int.MinValue` |
|
|
235
|
+
| `Int` | `readInt(Long.MinValue)` | `Long` | `Long.MinValue` |
|
|
236
|
+
| `Long` | `readLongs(scratch, 0, 1)` | `Array[Long]` | **negative returned count** |
|
|
237
|
+
| `Float` | `readFloat(Double.MaxValue)` | `Double` | `Double.MaxValue` |
|
|
238
|
+
| `Double` | `readDoubles(scratch, 0, 1)` | `Array[Double]` | **negative returned count** |
|
|
239
|
+
| `AnyRef` | `read(sentinel)` | the element type | the sentinel, by reference identity |
|
|
240
|
+
|
|
241
|
+
Three things are worth reading off that table.
|
|
242
|
+
|
|
243
|
+
**The sentinel travels in the widened carrier.** `readInt` returns `Long` rather than `Int` precisely so a value outside the element's domain exists to spend; `Boolean`, `Char`, and `Short` widen to `Int` for the same reason. `readByte()` is the exception that proves the rule — it yields unsigned bytes in `0..255`, so it can reserve `-1` permanently and takes no sentinel parameter at all.
|
|
244
|
+
|
|
245
|
+
**`Long` and `Double` use no sentinel.** For those two lanes the carrier is the element type itself, and there is no `Long` value and no `Double` bit pattern — NaN payloads and signed zero included — that is not also legitimate data, so every candidate sentinel collides with something a stream may legitimately carry. They detect end of stream by count instead: a length-one `readLongs` or `readDoubles` whose returned count is negative. The one-element scratch array is allocated once by the owner, outside the loop, never inside it. That is what makes these two lanes fully lossless — every `Long` value and every `Double` bit pattern stays readable as data.
|
|
246
|
+
|
|
247
|
+
**`readLong` and `readDouble` still take a sentinel parameter.** They are kept for symmetry with the five other sentinel-taking pulls, and for callers who can prove that a particular sentinel lies outside their own data domain. Without that qualifier the prose would contradict the signatures; with it, the point stands, because nothing can safely fill the parameter in general-purpose code and the library never relies on it. `SinkExactLaneSpec` and `RouteSpecializationProofSpec` install probe readers whose scalar `readLong` and `readDouble` throw an `AssertionError` even on their own matching lane, so a regression that reintroduced sentinel detection fails a test rather than silently truncating a value.
|
|
248
|
+
|
|
249
|
+
:::warning[Do not detect end of stream with `readLong` or `readDouble`]
|
|
250
|
+
Any value you reserve as an end marker on those two lanes is also a value the stream may carry, and the truncation is silent. Use `readLongs(scratch, 0, 1)` or `readDoubles(scratch, 0, 1)` and branch on the returned count.
|
|
251
|
+
:::
|
|
252
|
+
|
|
253
|
+
[Reader](../primitives/reader.md#sentinel-protocol) states the same contract from the implementor's side.
|
|
254
|
+
|
|
255
|
+
## Comparison: @specialized Vs JvmType Dispatch
|
|
256
|
+
|
|
257
|
+
ZIO Blocks Streams' approach differs fundamentally from Scala's traditional `@specialized` annotation. Here's how they compare:
|
|
258
|
+
|
|
259
|
+
**Traditional Scala `@specialized` annotation** generates separate specialized classes at compile time:
|
|
260
|
+
|
|
261
|
+
```scala
|
|
262
|
+
@specialized(Int, Long, Double)
|
|
263
|
+
class Stream[+E, +A] { ... }
|
|
264
|
+
// Generates separate classes:
|
|
265
|
+
// - Stream$mcI$sp (specialized for Int)
|
|
266
|
+
// - Stream$mcJ$sp (specialized for Long)
|
|
267
|
+
// - Stream$mcD$sp (specialized for Double)
|
|
268
|
+
// - Stream (generic fallback)
|
|
269
|
+
// Result: additional generated classes and bytecode
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**ZIO Blocks `JvmType` dispatch** records logical input and output types on stream and pipeline nodes. Compilation turns those types into interpreter lane and operator tags; it does not generate a separate `Stream` class for every primitive.
|
|
273
|
+
|
|
274
|
+
| Metric | `@specialized` | JvmType |
|
|
275
|
+
|--------|---|---|
|
|
276
|
+
| **Binary size** | Additional specialized classes | No class-per-primitive specialization |
|
|
277
|
+
| **Bytecode complexity** | High | Moderate |
|
|
278
|
+
| **Runtime dispatch** | Selected by specialized class | Interpreter lane and operator-tag dispatch |
|
|
279
|
+
| **Flexibility** | Fixed at compile time | Adaptive at runtime |
|
|
280
|
+
| **Primitive support** | Configurable | Boolean, Byte, Short, Char, Int, Long, Float, Double |
|
|
281
|
+
| **Generality** | Good for all generics | Specialized for Stream/Sink |
|
|
282
|
+
|
|
283
|
+
## Implementation Architecture
|
|
284
|
+
|
|
285
|
+
Primitive type metadata is carried across ZIO Blocks Streams' three core abstractions. Whether a concrete execution remains on primitive physical lanes depends on the interpreter and operation.
|
|
286
|
+
|
|
287
|
+
### Stream[E, A]
|
|
288
|
+
|
|
289
|
+
Stores element representation when a source is constructed and dispatches `Reader` accesses. Transformations request fresh evidence for their result, not for an input whose representation the stream already knows:
|
|
290
|
+
|
|
291
|
+
```scala
|
|
292
|
+
import zio.blocks.streams.*
|
|
293
|
+
|
|
294
|
+
val stream: Stream[Nothing, Int] = Stream(1, 2, 3)
|
|
295
|
+
// JvmType.Int is inferred and available to all operations
|
|
296
|
+
|
|
297
|
+
val asLong = stream.map(_.toLong)
|
|
298
|
+
// Infer[Long] records the transformed result representation
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Sink[E, A, Z]
|
|
302
|
+
|
|
303
|
+
Accepts elements via unboxed `write` methods matched to the detected type:
|
|
304
|
+
|
|
305
|
+
```scala
|
|
306
|
+
import zio.blocks.streams.*
|
|
307
|
+
import zio.blocks.chunk.Chunk
|
|
308
|
+
|
|
309
|
+
val nums = Stream(1, 2, 3)
|
|
310
|
+
val sum = nums.runFold(0)(_ + _)
|
|
311
|
+
// The synchronous interpreter can feed the fold through an Int lane
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Pipeline[A, B]
|
|
315
|
+
|
|
316
|
+
Preserves the input representation and records evidence for transformed results:
|
|
317
|
+
|
|
318
|
+
```scala
|
|
319
|
+
import zio.blocks.streams.*
|
|
320
|
+
|
|
321
|
+
val pipe = Pipeline.map[Int, Int](_ * 2)
|
|
322
|
+
// Infer[Int] describes the result. The input representation is supplied when
|
|
323
|
+
// the pipeline is applied to a Stream or Sink.
|
|
324
|
+
|
|
325
|
+
val throughStream = Stream(1, 2, 3).via(pipe)
|
|
326
|
+
val throughSink = pipe.andThenSink(Sink.sumInt)
|
|
327
|
+
// Both application routes propagate the same representation information.
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Performance Impact
|
|
331
|
+
|
|
332
|
+
Performance falls into distinct categories:
|
|
333
|
+
|
|
334
|
+
- **Scalar CPU pipelines** (`map`, `filter`, folds) benefit most from primitive lanes because boxing, allocation, and dispatch are a large share of the work.
|
|
335
|
+
- **Bulk memory paths** (`readInts`, `readLongs`, `readDoubles`, collection) additionally benefit from contiguous arrays and fewer per-element calls.
|
|
336
|
+
- **Bounded concurrency** pays queue/selector and scheduling costs; use it when callback or child latency dominates, not for trivial arithmetic.
|
|
337
|
+
- **Native asynchronous I/O** is governed mainly by source latency, chunk size, and cancellation/ownership costs; zero-boxing is usually secondary.
|
|
338
|
+
- **Writer `*Async` adapters** defer synchronous work and may still block, so they should not be benchmarked as native async I/O.
|
|
339
|
+
|
|
340
|
+
Do not infer a universal multiplier from these categories. Results depend on JDK, Scala version, platform, element type, pipeline shape, buffer size, and terminal; use the repository JMH benchmarks with a workload representative of the application. This page publishes no speedup figure for generalized specialization: any single multiplier would be drawn from a narrower set of benchmark shapes than the claim it was used to support.
|
|
341
|
+
|
|
342
|
+
Repository JMH results measure throughput for named benchmark methods and configurations, and a suite is evidence only about the contract it actually measures — a ready-effect suite measures ready effects, not asynchronous stream performance in general, and results for unlike contracts must not be aggregated into one ranking. Unless a run also records an allocation profiler (for example `gc.alloc.rate.norm`), it is **not** evidence of zero allocations; a near-zero figure from a throughput run is profiler noise, not a promise. The lane layout and primitive reader signatures establish where boxing is avoided by construction; claims about callback invocation, complete pipelines, async carriers, or parity with handwritten loops remain unproven until measured with allocation profiling for that exact workload.
|
|
343
|
+
|
|
344
|
+
## Async and Boxing
|
|
345
|
+
|
|
346
|
+
The claims above are about the synchronous interpreter, and the asynchronous one has an honest limit that belongs right next to them: **asynchronous execution is lane-aware, not end-to-end zero-boxing.**
|
|
347
|
+
|
|
348
|
+
Lane awareness is real. `AsyncInterpreter` keeps primitive lane state internally, the asynchronous puller resolves its exact per-element pull once from `reader.jvmType`, and `AsyncReader` exposes the same nine-identity pull surface as its synchronous counterpart, bulk `readLongs` and `readDoubles` included.
|
|
349
|
+
|
|
350
|
+
What changes is the carrier at the boundary. Every asynchronous result crosses a generic `Async[T]`, and a completed primitive in an `Async[Long]` is a `Long` in a generic position, so it may be boxed. The same applies wherever a primitive passes through another generic carrier: an erased Scala function, an `Option`, an `Either`, or the `Async[Either[E, Z]]` convention the asynchronous terminals use. That carrier-boundary boxing is a different thing from the error the lanes exist to prevent — pulling an upstream primitive through generic `read`, or routing it through the wrong lane — and only the second is ruled out by construction.
|
|
351
|
+
|
|
352
|
+
The practical consequence is that "zero-boxing" should not be read as a property of an asynchronous pipeline end to end. It describes the pull surface and the interpreter's own state, and stops at the carrier. As everywhere else on this page, whether a particular asynchronous workload allocates is a question for an allocation profiler. [Asynchronous Stream Execution](./async-execution.md#why-two-engines) explains why the two engines exist at all.
|
|
353
|
+
|
|
354
|
+
## When Polymorphism Is Necessary
|
|
355
|
+
|
|
356
|
+
If you need polymorphic behavior (e.g., different handling for different types), use `JvmType` directly:
|
|
357
|
+
|
|
358
|
+
```scala
|
|
359
|
+
import zio.blocks.streams.*
|
|
360
|
+
import zio.blocks.streams.JvmType
|
|
361
|
+
|
|
362
|
+
def processStream[A](stream: Stream[Nothing, A])(implicit jt: JvmType.Infer[A]): Unit = {
|
|
363
|
+
jt.jvmType match {
|
|
364
|
+
case JvmType.Int =>
|
|
365
|
+
println("Processing integers")
|
|
366
|
+
case JvmType.Long =>
|
|
367
|
+
println("Processing longs")
|
|
368
|
+
case _ =>
|
|
369
|
+
println("Processing generic type")
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
This gives code runtime type information. It does not by itself guarantee allocation-free execution; the selected operation and interpreter still determine the physical path.
|
|
375
|
+
|
|
376
|
+
## Summary
|
|
377
|
+
|
|
378
|
+
ZIO Blocks Streams reduces primitive boxing through:
|
|
379
|
+
|
|
380
|
+
1. **Compile-time output lanes** fixed by `JvmType.Infer[A]` implicit resolution
|
|
381
|
+
2. **Runtime input lanes** read from `reader.jvmType`, dispatched once per drain rather than per element
|
|
382
|
+
3. **Primitive synchronous accessors** across all nine identities, with widened sentinel carriers, and count-based end of stream on the two lossless lanes
|
|
383
|
+
4. **Lane-aware async interpretation**, while accepting that generic `Async[T]` carriers may box completed primitives
|
|
384
|
+
|
|
385
|
+
The result is specialized hot paths without `@specialized` class proliferation, available to every lane and every accumulator type rather than to one benchmark-shaped product. Some generic callbacks, reference values, tuples/case classes, and async/concurrent coordination can still allocate or box; verify important workloads with an allocation profiler rather than assuming allocation-free execution end to end.
|
|
386
|
+
|
|
387
|
+
## See Also
|
|
388
|
+
|
|
389
|
+
- [Reader](../primitives/reader.md#the-reader-union) — the two reader kinds, and the sentinel protocol from the implementor's side
|
|
390
|
+
- [Sink](../core/sink.md) — the typed sinks that drain through these lanes
|
|
391
|
+
- [Pipeline](../core/pipeline.md) — why the transforming factories ask for `JvmType.Infer` on their result type
|
|
392
|
+
- [Asynchronous Stream Execution](./async-execution.md#one-stream-type-two-execution-modes) — how a graph becomes synchronous or asynchronous, and why there is no mode annotation either
|
|
393
|
+
- [Platform Differences](./platform-differences.md) — what changes on Scala.js, where the same lanes apply
|