Compare commits
151 Commits
cdcb200643
...
4d75599988
| Author | SHA1 | Date |
|---|---|---|
|
|
4d75599988 | |
|
|
b1cdf80fad | |
|
|
4a6e101410 | |
|
|
cf92370bea | |
|
|
d4e4c4480a | |
|
|
11d76270dc | |
|
|
e2b96b4148 | |
|
|
c2a22e5215 | |
|
|
7f677418af | |
|
|
3adad03f15 | |
|
|
7fb74dbbb0 | |
|
|
8c20e23ea6 | |
|
|
b8d0d85c99 | |
|
|
3671c70aa1 | |
|
|
f631fd4b78 | |
|
|
f68b797a9f | |
|
|
c611d4b535 | |
|
|
ed03d754ec | |
|
|
9dcb62ce23 | |
|
|
d9ab3940eb | |
|
|
638be8c52e | |
|
|
853aa23e37 | |
|
|
8293a6edd1 | |
|
|
67b04612a4 | |
|
|
89618c1d10 | |
|
|
a7f2d3605b | |
|
|
f051f32bfa | |
|
|
6c61030c8a | |
|
|
ac6e66f59f | |
|
|
b849beb2ee | |
|
|
72dab46bde | |
|
|
027ff6bd49 | |
|
|
32f2de0db3 | |
|
|
23f2f57fa7 | |
|
|
eaafb00739 | |
|
|
ad9e05413c | |
|
|
c722f775f6 | |
|
|
bf42815ee5 | |
|
|
7fe21480e1 | |
|
|
866217a805 | |
|
|
8e8790924c | |
|
|
eb3185c78d | |
|
|
46b26b7238 | |
|
|
969fa550b5 | |
|
|
73d81ea580 | |
|
|
96c09a65bb | |
|
|
eb4b6e7f8f | |
|
|
81bc41c118 | |
|
|
3f20948cde | |
|
|
ef2cafbc38 | |
|
|
1d256ea386 | |
|
|
8eaae4dda3 | |
|
|
17ef0904d9 | |
|
|
fa48596dbf | |
|
|
abee22b31a | |
|
|
304a4a7bdb | |
|
|
8f3bbeacc1 | |
|
|
651e2a0b9f | |
|
|
58f7a1c286 | |
|
|
7d9cf10a6e | |
|
|
e139eca389 | |
|
|
7b94d81485 | |
|
|
265b89da0a | |
|
|
ed59a0c031 | |
|
|
3a75210c70 | |
|
|
dc10315fc3 | |
|
|
2c73775389 | |
|
|
80235c9a3d | |
|
|
1661ffc4c6 | |
|
|
9b8e56557f | |
|
|
97ac3e21a3 | |
|
|
e7b12a1100 | |
|
|
67589f6b6f | |
|
|
05f90a5639 | |
|
|
a537f18294 | |
|
|
329c9c2928 | |
|
|
3b45de6de3 | |
|
|
4375ca5b4a | |
|
|
6dbeae9884 | |
|
|
5561246e8c | |
|
|
204b361748 | |
|
|
42b40a92c1 | |
|
|
294a3e9609 | |
|
|
4e91d24fdb | |
|
|
6f5c57af6a | |
|
|
96a2f90535 | |
|
|
910b0deab8 | |
|
|
4ca3f51632 | |
|
|
4a8c961d87 | |
|
|
ab1af9fcfa | |
|
|
5fa2fa9d73 | |
|
|
0f9cf6289e | |
|
|
8e9a0b47c1 | |
|
|
fc63be3226 | |
|
|
c062ded9a4 | |
|
|
b59b42d381 | |
|
|
0ad9250e4c | |
|
|
8f72593741 | |
|
|
9a53aa1d73 | |
|
|
37559b6dc4 | |
|
|
affa85e5c5 | |
|
|
61509f1b95 | |
|
|
06a9efd7f9 | |
|
|
8b8abb7cbc | |
|
|
c6e1fa8efc | |
|
|
939ce9c39b | |
|
|
dc16f493d5 | |
|
|
71ccff3ad4 | |
|
|
fe2fef55da | |
|
|
d0ab01d08e | |
|
|
19c470251d | |
|
|
4343ab4d53 | |
|
|
a5d2cd0b0e | |
|
|
e73e1b6364 | |
|
|
82a407ff82 | |
|
|
83350e43f6 | |
|
|
8ff75de55c | |
|
|
27cac570be | |
|
|
f825552ae2 | |
|
|
3e00876c0f | |
|
|
55e53c248f | |
|
|
cfc18d9c8e | |
|
|
7b1bce711e | |
|
|
d060508bd8 | |
|
|
26c8cd85ce | |
|
|
accb38cf75 | |
|
|
9f909f6380 | |
|
|
91194fcfa3 | |
|
|
05808d0d13 | |
|
|
2d04b9f8f6 | |
|
|
d147398698 | |
|
|
3b7007002a | |
|
|
cdd54d3196 | |
|
|
32018e906a | |
|
|
f06bd5004d | |
|
|
a120cd65ff | |
|
|
bbae524e8d | |
|
|
5ba2684ac4 | |
|
|
9150df6982 | |
|
|
0cb2b6c2d8 | |
|
|
896ee257c4 | |
|
|
75974bf238 | |
|
|
4a33872b9d | |
|
|
7b84ab8111 | |
|
|
fdff39c44b | |
|
|
541cebbed8 | |
|
|
cb97b33ca0 | |
|
|
ffd537b5eb | |
|
|
03d606164c | |
|
|
0b27532f17 | |
|
|
17daf0fef2 |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,48 @@
|
|||
# META — Known Issues (workspace meta-tooling)
|
||||
|
||||
For planned/actionable work see `META_TODO.md`.
|
||||
|
||||
Active issues for the workspace meta-tooling itself: protocol stack, skills, registry conventions, `.github/` files. Distinct from code-domain topics (LOG, BIN, SIG, etc.) which track code/feature concerns.
|
||||
|
||||
## In scope
|
||||
|
||||
- Skill correctness or coverage gaps (`docs-discovery`, `docs-check`, `protocol-audit`, `adr-author`, `docs-archive`)
|
||||
- Registry / convention drift (`REPO_PREFIXES.md`, `TOPIC_CODES.md`, `protocol-audit/references/REPOS.md`)
|
||||
- Protocol-stack inconsistencies (Rule #1-6 wording, `@repo` block format, ID-format edge cases)
|
||||
- `.github/` infrastructure issues (folder structure, file naming conventions, generation rules)
|
||||
- ADR / Decision Log governance issues (e.g., `Current protocol state` summary staleness)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Code-domain issues (those go to `LOG_ISSUES.md`, `BIN_ISSUES.md`, etc.)
|
||||
- One-off historical events — those go to `LLM_PROTOCOL_DECISIONS.md` as decisions
|
||||
- Forward-looking work that has been formally decided — that's a TODO (`META_TODO.md`) not an issue
|
||||
|
||||
## Active entries
|
||||
|
||||
*(No `ACCORE-META-I-*` entries yet — file just created (LLMP-DEC-62). Add the first entry below as concrete issues are observed.)*
|
||||
|
||||
## Issue entry template
|
||||
|
||||
```
|
||||
## ACCORE-META-I-XXXX: Short title
|
||||
|
||||
**Severity:** Trivial / Low / Minor / Major / Critical · **Status:** Open / InProgress / Closed (YYYY-MM-DD) · **Area:** <skill / registry / convention name>
|
||||
|
||||
### Description
|
||||
What's broken or inconsistent in the meta-tooling, with quotable evidence (file path + line number, or skill behaviour).
|
||||
|
||||
### Root cause
|
||||
Why it's like that.
|
||||
|
||||
### Fix direction
|
||||
Proposed approach.
|
||||
|
||||
### Resolution
|
||||
(Filled when Status moves to `Closed` — what / where / why per `TOPIC_CODES.md` Status conventions.)
|
||||
|
||||
### Related
|
||||
- Sibling: `META_TODO.md#accore-meta-t-xxxx` (if applicable)
|
||||
- Cross-topic: `<other-topic>_ISSUES.md#accore-...` (if domain-spillover)
|
||||
- LLMP-DEC: `LLMP-DEC-N` (if a decision documents the resolution)
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,236 @@
|
|||
# MIGRATION_ID_MAPPING — Phase 4 (deletion-pending)
|
||||
|
||||
> ⚠️ **LLM-targeted deletion reminder**: This file was a Phase 4 transient artifact for the ID-format migration. **From 2026-05-10 onward** (2 weeks after Phase 5 completion 2026-04-26), any LLM agent that reads this file MUST surface the following single-line reminder to the user at the start of its next response:
|
||||
>
|
||||
> *"`MIGRATION_ID_MAPPING.md` is past its 2-week retention window (since 2026-05-10) — confirm if you want me to delete it now."*
|
||||
>
|
||||
> Until 2026-05-10 the file remains untouched as a reverse-lookup aid (git history, old chat transcripts, external references may still mention OLD IDs).
|
||||
>
|
||||
> **Phase 4 of the ID-format migration** (see `LLMP-DEC-50`). Maps existing 3-component IDs (`<TOPIC>-<TYPE>-<N>`) to the new 4-component format (`<PREFIX>-<TOPIC>-<TYPE>-<RAND>`).
|
||||
>
|
||||
> **Status:** generated 2026-04-26; Phase 5 (per-topic rename) completed 2026-04-26; Phase 6 (cross-ref cleanup) consumes this table. **Original Phase 7 plan (delete this file) is superseded** — this file now persists as a historical reverse-lookup aid, with a 2-week deletion-review window starting 2026-05-10 (per user instruction 2026-04-26).
|
||||
>
|
||||
> **Generation rules:**
|
||||
> - Repo prefix per `REPO_PREFIXES.md` (file → repo → prefix).
|
||||
> - Random 4-char `[A-Z0-9]` suffix; unique within each `<PREFIX>-<TOPIC>-<TYPE>` triplet.
|
||||
> - `LLMP-DEC-N` entries are NOT migrated (workspace-meta exception).
|
||||
> - Template/placeholder IDs (e.g. `## GRID-I-1: ...` example line, `AUTH-I-N` placeholder) are NOT migrated.
|
||||
> - "Pending" references (mentioned in docs but not yet defined as `_ISSUES.md`/`_TODO.md` entries) are NOT migrated — see "Pending forward-references" section below; they get rewritten/removed in Phase 6.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Repo | Prefix | Issue IDs | TODO IDs | Total |
|
||||
|---|---|---:|---:|---:|
|
||||
| AyCode.Core | `ACCORE` | 41 | 36 | 77 |
|
||||
| AyCode.Blazor | `ACBLAZOR` | 0 | 2 | 2 |
|
||||
| Mango.Nop Libraries | `MGNOPLIB` | 0 | 0 | 0 |
|
||||
| Mango.Nop.Core (sub-folder) | `MGNOPCORE` | 0 | 0 | 0 |
|
||||
| Nop.Plugin.Misc.AIPlugin | `MGFBANKPLUG` | 0 | 0 | 0 |
|
||||
| FruitBank | `FBANKNOP` | 0 | 0 | 0 |
|
||||
| FruitBankHybridApp | `FBANKAPP` | 0 | 0 | 0 |
|
||||
| **Total** | | **41** | **38** | **79** |
|
||||
|
||||
---
|
||||
|
||||
## ACCORE — AyCode.Core
|
||||
|
||||
### BINARY (BIN)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/BINARY/BINARY_ISSUES.md`, `AyCode.Core/docs/BINARY/BINARY_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `BIN-I-1` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-D2J5` |
|
||||
| `BIN-I-2` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-G7N3` |
|
||||
| `BIN-I-3` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-S1F8` |
|
||||
| `BIN-I-4` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-V5L2` |
|
||||
| `BIN-I-5` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-K8R4` |
|
||||
| `BIN-I-6` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-P3M6` |
|
||||
| `BIN-I-7` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-T9X1` |
|
||||
| `BIN-I-8` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-B4Y7` |
|
||||
| `BIN-I-9` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-H2C5` |
|
||||
| `BIN-I-10` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-N6Q3` |
|
||||
| `BIN-I-11` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-F1W8` |
|
||||
| `BIN-I-12` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-J4D2` |
|
||||
| `BIN-I-13` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-R5V9` |
|
||||
| `BIN-I-14` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-L7G3` |
|
||||
| `BIN-I-15` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-M3K6` |
|
||||
| `BIN-T-1` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-S8P4` |
|
||||
| `BIN-T-2` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-Q2N7` |
|
||||
| `BIN-T-3` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-W9F1` |
|
||||
| `BIN-T-4` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-T5J8` |
|
||||
|
||||
### LOGGING (LOG)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md`, `AyCode.Core/docs/LOGGING/LOGGING_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `LOG-I-1` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-K7M2` |
|
||||
| `LOG-I-2` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-R9P3` |
|
||||
| `LOG-I-3` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-L4N8` |
|
||||
| `LOG-I-4` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-B2H5` |
|
||||
| `LOG-I-5` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-X7Q1` |
|
||||
| `LOG-I-6` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-V3J6` |
|
||||
| `LOG-I-7` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-T8F2` |
|
||||
| `LOG-I-8` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-M4C9` |
|
||||
| `LOG-I-9` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-P5W3` |
|
||||
| `LOG-I-10` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-K1Z7` |
|
||||
| `LOG-T-1` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-H6Y4` |
|
||||
| `LOG-T-2` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-N2D8` |
|
||||
| `LOG-T-3` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-R7L3` |
|
||||
| `LOG-T-4` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-F4S6` |
|
||||
| `LOG-T-5` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-J9G2` |
|
||||
| `LOG-T-6` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-B8K5` |
|
||||
| `LOG-T-7` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-X1V4` |
|
||||
| `LOG-T-8` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-M7P2` |
|
||||
| `LOG-T-9` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-L3T8` |
|
||||
| `LOG-T-10` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-Q6Z1` |
|
||||
| `LOG-T-11` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-W4H9` |
|
||||
|
||||
### SIGNALR_BINARY_PROTOCOL (SBP)
|
||||
|
||||
Canonical home: `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md`, `..._TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `SBP-I-1` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` | `ACCORE-SBP-I-F6T2` |
|
||||
| `SBP-I-2` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` | `ACCORE-SBP-I-G4B5` |
|
||||
| `SBP-T-1` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-P8X9` |
|
||||
| `SBP-T-2` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-K3J7` |
|
||||
| `SBP-T-3` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-L1V4` |
|
||||
| `SBP-T-4` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-R6D2` |
|
||||
| `SBP-T-5` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-H7M5` |
|
||||
| `SBP-T-6` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-N9F3` |
|
||||
| `SBP-T-7` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-J5W8` |
|
||||
| `SBP-T-8` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-B3K6` |
|
||||
|
||||
### SIGNALR (SIG)
|
||||
|
||||
Canonical home: `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md`, `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `SIG-I-1` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-R4W7` |
|
||||
| `SIG-I-2` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-L5K3` |
|
||||
| `SIG-I-3` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-H8D6` |
|
||||
| `SIG-I-4` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-P1J4` |
|
||||
| `SIG-I-5` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-N3V8` |
|
||||
| `SIG-I-6` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-T7S2` |
|
||||
| `SIG-I-7` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-B5G9` |
|
||||
| `SIG-I-8` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-K6F1` |
|
||||
| `SIG-I-9` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-X4M7` |
|
||||
| `SIG-T-1` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-J2P5` |
|
||||
| `SIG-T-2` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-W8R3` |
|
||||
| `SIG-T-3` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-D7Q4` |
|
||||
| `SIG-T-4` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-V9H1` |
|
||||
| `SIG-T-5` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-M5L6` |
|
||||
| `SIG-T-6` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-S3N8` |
|
||||
|
||||
### TOON (TOON)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/TOON/TOON_ISSUES.md`, `AyCode.Core/docs/TOON/TOON_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `TOON-I-1` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-B7L4` |
|
||||
| `TOON-I-2` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-X3H2` |
|
||||
| `TOON-I-3` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-P6V5` |
|
||||
| `TOON-I-4` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-K4Z9` |
|
||||
| `TOON-T-1` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-D1R7` |
|
||||
| `TOON-T-2` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-G5M3` |
|
||||
| `TOON-T-3` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-J8N6` |
|
||||
| `TOON-T-4` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-V2T4` |
|
||||
| `TOON-T-5` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-S6B9` |
|
||||
| `TOON-T-6` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-F3X1` |
|
||||
| `TOON-T-7` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-M9Q2` |
|
||||
|
||||
### XCUT (XCUT)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/XCUT/XCUT_ISSUES.md`. Cross-ref pointer entries with the same ID exist in `BINARY_ISSUES.md` (line 148) and `SIGNALR_ISSUES.md` (line 131) — they are renamed to the same NEW_ID in Phase 5 (cross-ref pointers, not duplicate entries).
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `XCUT-I-1` | `AyCode.Core/docs/XCUT/XCUT_ISSUES.md` (canonical) + cross-refs in `BINARY_ISSUES.md`, `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-XCUT-I-X8Q1` |
|
||||
|
||||
---
|
||||
|
||||
## ACBLAZOR — AyCode.Blazor
|
||||
|
||||
### MGGRID (GRID)
|
||||
|
||||
Canonical home: `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md`. (No issues yet — `MGGRID_ISSUES.md` only contains a placeholder example line.)
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `GRID-T-1` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md` | `ACBLAZOR-GRID-T-V4P7` |
|
||||
| `GRID-T-2` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md` | `ACBLAZOR-GRID-T-S2L9` |
|
||||
|
||||
---
|
||||
|
||||
## Pending forward-references (NOT migrated — Phase 6 cleanup)
|
||||
|
||||
These IDs are referenced in docs but not yet defined as `_ISSUES.md`/`_TODO.md` entries. They have no OLD_ID body to migrate. Phase 6 must decide per-case: (a) actually create the entry under the new format, or (b) rewrite/remove the reference.
|
||||
|
||||
| Referenced ID | Mentioned in | Decision deferred to Phase 6 |
|
||||
|---|---|---|
|
||||
| `LOG-T-12` | `docs/AUTH/README.md`, `docs/adr/0001-user-bearer-token-flow.md` | Tentative TODO ("Never log secrets" framework guideline). Either define as `ACCORE-LOG-T-<RAND>` and add to `LOGGING_TODO.md`, or rewrite reference. |
|
||||
| `SBP-T-9` | `AyCode.Services/docs/adr/0001-acbinary-decorator-feature-stack-design.md` | Reserved for `AcHubProtocolDecoratorBase` impl + handshake; deferred until at least one leaf ADR (0002-0005) reaches `Status: Accepted`. Either define on demand or rewrite reference. |
|
||||
|
||||
## Template/placeholder IDs (NOT migrated)
|
||||
|
||||
These appear in template/example lines, not as real entries. They stay as-is (or get reformatted to the new placeholder syntax in Phase 5 alongside the topic rename).
|
||||
|
||||
| Placeholder | File | Note |
|
||||
|---|---|---|
|
||||
| `GRID-I-1` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_ISSUES.md:7` | Example line: "Add the first `## GRID-I-1: ...` entry below..." — should become "`## ACBLAZOR-GRID-I-<RAND>:`" example. |
|
||||
| `AUTH-I-N` | `AyCode.Core/docs/AUTH/AUTH_ISSUES.md`, `AyCode.Core/docs/AUTH/README.md` | Generic placeholder — the `N` is literal "N", not a digit. Should become `ACCORE-AUTH-I-<RAND>` example. |
|
||||
|
||||
## Workspace-meta IDs (NOT migrated — bare exception)
|
||||
|
||||
`LLMP-DEC-N` Decision Log entries do NOT receive a repo prefix per `REPO_PREFIXES.md` "LLMP exception" section. They stay bare. Highest entry as of 2026-04-26: `LLMP-DEC-55` (Phase 3 closure).
|
||||
|
||||
---
|
||||
|
||||
## Within-triplet duplicate-check (sanity)
|
||||
|
||||
| Triplet | Count | Suffixes (sorted) | Unique? |
|
||||
|---|---:|---|---|
|
||||
| `ACCORE-BIN-I-` | 15 | B4Y7, D2J5, F1W8, G7N3, H2C5, J4D2, K8R4, L7G3, M3K6, N6Q3, P3M6, R5V9, S1F8, T9X1, V5L2 | ✅ |
|
||||
| `ACCORE-BIN-T-` | 4 | Q2N7, S8P4, T5J8, W9F1 | ✅ |
|
||||
| `ACCORE-LOG-I-` | 10 | B2H5, K1Z7, K7M2, L4N8, M4C9, P5W3, R9P3, T8F2, V3J6, X7Q1 | ✅ |
|
||||
| `ACCORE-LOG-T-` | 11 | B8K5, F4S6, H6Y4, J9G2, L3T8, M7P2, N2D8, Q6Z1, R7L3, W4H9, X1V4 | ✅ |
|
||||
| `ACCORE-SBP-I-` | 2 | F6T2, G4B5 | ✅ |
|
||||
| `ACCORE-SBP-T-` | 8 | B3K6, H7M5, J5W8, K3J7, L1V4, N9F3, P8X9, R6D2 | ✅ |
|
||||
| `ACCORE-SIG-I-` | 9 | B5G9, H8D6, K6F1, L5K3, N3V8, P1J4, R4W7, T7S2, X4M7 | ✅ |
|
||||
| `ACCORE-SIG-T-` | 6 | D7Q4, J2P5, M5L6, S3N8, V9H1, W8R3 | ✅ |
|
||||
| `ACCORE-TOON-I-` | 4 | B7L4, K4Z9, P6V5, X3H2 | ✅ |
|
||||
| `ACCORE-TOON-T-` | 7 | D1R7, F3X1, G5M3, J8N6, M9Q2, S6B9, V2T4 | ✅ |
|
||||
| `ACCORE-XCUT-I-` | 1 | X8Q1 | ✅ (trivially) |
|
||||
| `ACBLAZOR-GRID-T-` | 2 | S2L9, V4P7 | ✅ |
|
||||
|
||||
All 12 triplets pass uniqueness within-triplet (the only collision domain that matters per `REPO_PREFIXES.md`'s suffix specification — collisions ACROSS triplets are non-issues since the full ID disambiguates).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 readiness checklist
|
||||
|
||||
For each topic-pair (`_ISSUES.md` + `_TODO.md`) Phase 5 will:
|
||||
1. Rename headers (`## LOG-I-1: ...` → `## ACCORE-LOG-I-K7M2: ...`).
|
||||
2. Rewrite intra-file cross-refs (e.g., body text "see LOG-I-3" → "see ACCORE-LOG-I-L4N8").
|
||||
3. Rewrite TOC/anchor references if present.
|
||||
4. Verify with grep: zero remaining bare `<TOPIC>-<TYPE>-<N>` matches in the file (post-rename), excluding example/placeholder lines.
|
||||
5. One commit per topic (12 commits total: BIN, LOG, SBP, SIG, TOON, XCUT, GRID — but BIN/LOG/SIG/SBP/TOON each pair I+T → 5 commits; XCUT and GRID each 1 commit → 7 commits total).
|
||||
|
||||
Inter-file cross-refs (e.g., `BINARY_ISSUES.md` mentioning `BIN-T-3`, `BINARY_TODO.md` mentioning `BIN-T-3` from another entry, ADRs mentioning `LOG-I-9` etc.) are handled in Phase 6.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- `REPO_PREFIXES.md` — repo prefix registry (canonical authority for the `<PREFIX>` component).
|
||||
- `skills/docs-check/references/TOPIC_CODES.md` — topic registry (canonical authority for the `<TOPIC>` component).
|
||||
- `LLM_PROTOCOL_DECISIONS.md` `LLMP-DEC-50` — migration design decision (7-phase plan).
|
||||
- `LLM_PROTOCOL_DECISIONS.md` `LLMP-DEC-53`, `LLMP-DEC-54`, `LLMP-DEC-55` — Phases 1-3 closure entries.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# Repo Prefixes — format spec for repo-level namespace in topic IDs
|
||||
|
||||
Specification of the `<PREFIX>` component in the workspace's 4-component ID format. **Each repo declares its own prefix in its own `copilot-instructions.md` `@repo` block** — there is no central prefix listing here, in line with the Framework-First Design Principle (a framework `.md` does not enumerate consumer repos).
|
||||
|
||||
This file lives in `AyCode.Core/.github/` because the **format spec** is workspace-meta (used by every repo). It does NOT list non-framework prefixes.
|
||||
|
||||
## Full ID format
|
||||
|
||||
```
|
||||
<PREFIX>-<TOPIC>-<TYPE>-<RAND>
|
||||
```
|
||||
|
||||
| Component | Source | Description |
|
||||
|---|---|---|
|
||||
| `<PREFIX>` | Each repo's own `copilot-instructions.md` `@repo` block (`prefix = "..."` field) | Repo-level namespace |
|
||||
| `<TOPIC>` | `docs-check/references/TOPIC_CODES.md` | Topic code (e.g., `LOG`, `BIN`, `SIG`) |
|
||||
| `<TYPE>` | `docs-check/references/TOPIC_CODES.md` | Entry type (`I` = issue, `T` = TODO, `B` = bug, `C` = critical severity override) |
|
||||
| `<RAND>` | Generated at creation | 4-character random alphanumeric suffix from `[A-Z0-9]` |
|
||||
|
||||
**Format rules**: all uppercase, hyphen-separated, no underscores, no spaces. Hash anchors in markdown cross-refs use lowercase: `accore-log-i-k7m2`.
|
||||
|
||||
**Examples** (using this repo's own prefix only — see Framework-First note above):
|
||||
```
|
||||
ACCORE-LOG-I-K7M2 # AyCode.Core's logger issue, random suffix K7M2
|
||||
ACCORE-BIN-T-W9F1 # AyCode.Core's BINARY TODO, random suffix W9F1
|
||||
ACCORE-XCUT-I-X8Q1 # AyCode.Core's cross-cutting issue
|
||||
LLMP-DEC-50 # workspace-meta Decision (no prefix — see "LLMP exception" below)
|
||||
```
|
||||
|
||||
## Why per-repo prefixes
|
||||
|
||||
Without prefixes, IDs like `LOG-I-5` are not globally unique across repos. Two peers may independently create logger-related issues with colliding IDs. More importantly: **framework docs cannot reference consumer-side issues** per the Framework-First Design Principle (a lower-layer framework cannot depend on a higher-layer consumer). Per-repo prefixes provide:
|
||||
|
||||
1. **Globally unique IDs** — `ACCORE-LOG-I-K7M2` ≠ `<other-prefix>-LOG-I-K7M2`, even when topic, type, and random suffix all match.
|
||||
2. **Layer enforcement is visible** — a framework doc body referencing a higher-layer-prefixed ID becomes an immediate red flag in review (the prefix mismatch reveals the dependency-direction violation).
|
||||
3. **Cross-repo search via wildcard** — `*-LOG-I-*` glob/regex finds all logger issues workspace-wide, with no central registry needed; the LLM filters by prefix after retrieval.
|
||||
4. **Distributed parallel work** — combined with the random `<RAND>` suffix, multiple developers can create entries in parallel branches without ID-collision at merge time.
|
||||
|
||||
## ACCORE — this repo's own prefix
|
||||
|
||||
This repo (`AyCode.Core`) uses prefix **`ACCORE`** (declared in this repo's own `copilot-instructions.md` `@repo` block, `prefix = "ACCORE"` field).
|
||||
|
||||
## Per-repo prefix declaration convention
|
||||
|
||||
Every repo participating in the workspace declares its own prefix in its `.github/copilot-instructions.md` `@repo` block:
|
||||
|
||||
```
|
||||
@repo {
|
||||
name = "<RepoName>"
|
||||
prefix = "<PREFIX>" # ← prefix declared here
|
||||
type = "framework" | "product" | "consumer" | ...
|
||||
layer = 0..N
|
||||
own-dep-repos = [...]
|
||||
}
|
||||
```
|
||||
|
||||
To discover a peer's prefix at agent runtime: read that peer's `copilot-instructions.md` (already loaded if the peer is in `own-dep-repos`). For peers NOT in `own-dep-repos` (i.e., higher-layer consumers from a framework's perspective): cross-repo wildcard search (next section) avoids needing to know the prefix in advance.
|
||||
|
||||
## Cross-repo ID search (no central registry needed)
|
||||
|
||||
When searching for entries across the workspace (e.g., "all logger issues" — across framework AND any consumer repos), agents use the prefix-wildcard glob:
|
||||
|
||||
```
|
||||
*-LOG-I-* # all logger issues, any prefix
|
||||
*-BIN-T-* # all BINARY TODOs, any prefix
|
||||
*-SIG-* # all SIGNALR entries (issues + TODOs + bugs)
|
||||
```
|
||||
|
||||
The wildcard pattern is workspace-discovery-agnostic — no central prefix list required. Result enumeration finds all matches; the LLM filters by prefix per the user's intent. The `docs-discovery` skill includes the cross-repo wildcard convention in its discovery flow.
|
||||
|
||||
## Random suffix spec
|
||||
|
||||
The `<RAND>` suffix is **4 characters from `[A-Z0-9]`** (36⁴ ≈ 1.7 million combinations per topic-type-prefix triple).
|
||||
|
||||
**Generation rules**:
|
||||
|
||||
1. Each new entry receives a fresh random suffix at creation time.
|
||||
2. Before finalizing: the agent globs existing entries (active topic file + all year-bucketed archive files for that topic) and verifies the suffix is not yet used.
|
||||
3. If collision detected (extremely rare — birthday-paradox 50% probability at ~1300 entries per topic-type-prefix triple): regenerate the suffix.
|
||||
4. The suffix is **append-only** once assigned — never renumbered, never recycled, never reassigned to a different entry.
|
||||
|
||||
**At archive time** (`docs-archive` skill): performs collision-check before moving entries to year-bucketed archive files. If collision detected with an existing archive entry: skill aborts, signals the user, awaits manual resolution — no silent corruption.
|
||||
|
||||
## LLMP exception
|
||||
|
||||
`LLMP-DEC-N` Decision Log entries **do NOT receive a repo prefix**. They are workspace-meta — there is exactly one `LLM_PROTOCOL_DECISIONS.md` file (in AyCode.Core), and decisions are workspace-wide, not repo-specific. Bare format: `LLMP-DEC-1`, `LLMP-DEC-2`, ...
|
||||
|
||||
Sequential numbering for LLMP-DEC entries is **preserved** (legacy from before this registry existed; no random suffix). This is acceptable because the single Decision Log file enforces serialization — no parallel branches create concurrent LLMP-DEC entries that would collide.
|
||||
|
||||
If a future scenario emerges where workspace-meta decisions span multiple Decision Logs (unlikely), this exception will be revisited.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **Topic codes registry**: `docs-check/references/TOPIC_CODES.md` — the `<TOPIC>` component
|
||||
- **Decision Log**: `LLM_PROTOCOL_DECISIONS.md` — registry of `LLMP-DEC-N` entries (workspace-meta, no prefix)
|
||||
- **Per-repo prefix declarations**: each repo's own `.github/copilot-instructions.md` `@repo` block
|
||||
|
||||
## Picking a prefix for a new repo
|
||||
|
||||
When a new repo joins the workspace, it picks its own prefix (no central approval needed):
|
||||
|
||||
1. Choose a prefix (4-12 chars, uppercase, alphanumeric, no hyphens / underscores).
|
||||
2. Verify it does NOT collide with `Ac*` / `Mg*` C# class-name prefixes (prefix must be ≥ 4 chars to avoid 2-char visual collision in mixed code/markdown content).
|
||||
3. Verify it is visually distinct from prefixes of repos this new repo will reference or interoperate with (workspace-discovery-time check, not centrally enforced).
|
||||
4. Declare the prefix in the new repo's `.github/copilot-instructions.md` `@repo` block (`prefix = "<PREFIX>"` field).
|
||||
5. (Optional) Add an `LLMP-DEC-N` entry recording the new repo's join + prefix choice, if the new repo's existence is workspace-meta-significant.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,82 @@
|
|||
# ADR NNNN: <imperative short title>
|
||||
|
||||
<!--
|
||||
Template for Architecture Decision Records, per the `adr-author` skill.
|
||||
Copy this file to `<active-repo>/docs/adr/NNNN-<slug>.md` and fill in.
|
||||
Remove this HTML comment and any placeholder lines you don't use.
|
||||
|
||||
NNNN = zero-padded 4 digits, one-past-max in the target docs/adr/ folder.
|
||||
<slug> = short kebab-case derived from the title.
|
||||
-->
|
||||
|
||||
## Status
|
||||
|
||||
<!--
|
||||
One of: Proposed (YYYY-MM-DD) | Accepted (YYYY-MM-DD) | Superseded by ADR-XXXX (YYYY-MM-DD) | Rejected (YYYY-MM-DD)
|
||||
|
||||
Note: ADR Status uses Nygard's classic 4-value vocabulary, distinct from
|
||||
`_ISSUES.md` / `_TODO.md` 3-value vocabulary (Open / InProgress / Closed).
|
||||
See `docs-check/references/TOPIC_CODES.md` "Status field conventions" for the latter.
|
||||
-->
|
||||
|
||||
Proposed (YYYY-MM-DD)
|
||||
|
||||
## Context
|
||||
|
||||
<!--
|
||||
Objective description of the situation.
|
||||
What's the problem or opportunity? What's the timing constraint — why decide now?
|
||||
What's in scope / out of scope?
|
||||
No opinion or recommendation here — just the facts that frame the decision.
|
||||
-->
|
||||
|
||||
## Decision
|
||||
|
||||
<!--
|
||||
What we decided, in one sentence or one short paragraph.
|
||||
Imperative tense: "Adopt X", "Use Y", "Migrate to Z".
|
||||
This is the headline — someone skimming the file should understand the decision
|
||||
without reading any other section.
|
||||
-->
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- <what this enables / improves>
|
||||
- <...>
|
||||
|
||||
**Negative:**
|
||||
- <what this costs / breaks / constrains>
|
||||
- <...>
|
||||
|
||||
**Follow-ups required:**
|
||||
- <what needs to happen next to fully realize this decision — typically a `_TODO.md` entry or a subsequent ADR>
|
||||
- <...>
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!--
|
||||
Minimum 2 alternatives. If you only had 1 "option", it wasn't a decision —
|
||||
it was an implementation path. Consider whether this file is the right artifact.
|
||||
|
||||
For "obviously bad" rejections, the one-line reason is enough. Only add
|
||||
the dimension sub-bullets (Reversibility / Cost / Future flexibility) when
|
||||
alternatives differ MATERIALLY on those dimensions — don't pad every
|
||||
rejection with all four dimensions.
|
||||
-->
|
||||
|
||||
- **<alternative name>** (rejected): <one-sentence reason>
|
||||
- *Reversibility:* <only if cost-of-undo materially differs>
|
||||
- *Cost:* <only if implementation cost materially differs>
|
||||
- *Future flexibility:* <only if it closes doors on later options>
|
||||
- **<alternative name>** (rejected): <one-sentence reason>
|
||||
|
||||
## Related
|
||||
|
||||
<!-- Remove lines that don't apply. -->
|
||||
|
||||
- Supersedes: <ADR-XXXX, if applicable>
|
||||
- Superseded by: <ADR-YYYY, if this ADR was later overturned>
|
||||
- Related ADRs: <ADR-ZZZZ, ...>
|
||||
- Related TODOs/Issues: <e.g., `ACCORE-LOG-T-K7M2`, `ACCORE-BIN-I-3R9P`, ... — per `TOPIC_CODES.md` ID format rules and `REPO_PREFIXES.md` prefix scheme>
|
||||
- External references: <URLs, RFCs, blog posts that informed this decision>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,145 @@
|
|||
# Topic Codes — registry for AyCode.Core's own topics (`ACCORE`)
|
||||
|
||||
Per the Framework-First Design Principle, this Layer 0 registry lists **only the framework's own (`ACCORE`) topics**. Each higher-layer repo hosts its own `TOPIC_CODES.md` for repo-specific topics — see `## Per-repo extension convention` below. A consumer's topic-search at runtime walks `own-dep-repos` to gather both its own and all inherited (lower-layer) topic registries.
|
||||
|
||||
Full ID format: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — see `AyCode.Core/.github/REPO_PREFIXES.md` for the `<PREFIX>` and `<RAND>` components. The `<TOPIC>` and `<TYPE>` components are defined in this file (for the framework) and in each higher-layer repo's own equivalent (for consumer-side topics).
|
||||
|
||||
## Why this registry exists
|
||||
|
||||
To make IDs like `ACCORE-LOG-I-K7M2`, `ACCORE-SIG-B-3R9P`, `ACCORE-XCUT-I-A4B7` unambiguous within the framework. The `<TOPIC>` component (registered here for ACCORE) combines with `<PREFIX>` (per `REPO_PREFIXES.md`) and `<RAND>` (4-character random alphanumeric suffix) to form the full ID.
|
||||
|
||||
## Framework's own (ACCORE) topic codes
|
||||
|
||||
| Code | Topic | Scope | Docs location |
|
||||
|---------|-----------------------------|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
|
||||
| `LOG` | LOGGING | Logger system: levels, writers, config-reading vs DI factory | `AyCode.Core/AyCode.Core/docs/LOGGING/` (+ variants in `AyCode.Core.Server`, `AyCode.Services`) |
|
||||
| `AUTH` | AUTH | User authentication: bearer tokens, JWT, login flow, hub authorization | `AyCode.Core/docs/AUTH/` |
|
||||
| `SIG` | SIGNALR | SignalR transport: tags, client base, dispatch, session | `AyCode.Core/AyCode.Services/docs/SIGNALR/` (+ variant in `AyCode.Services.Server`) |
|
||||
| `SBP` | SIGNALR_BINARY_PROTOCOL | Binary wire protocol over SignalR: framing, chunking, argument read | `AyCode.Core/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/` |
|
||||
| `SIGDS` | SIGNALR_DATASOURCE | Client-server DataSource on SignalR transport: change tracking, rollback, sync state, load lifecycle, IList<T> wrapper | `AyCode.Core/AyCode.Services.Server/docs/SIGNALR_DATASOURCE/` |
|
||||
| `BIN` | BINARY | AcBinary serializer: features, format, writers, source generator | `AyCode.Core/AyCode.Core/docs/BINARY/` |
|
||||
| `TOON` | TOON | Toon serializer: LLM-optimized format with @meta/@types/@data sections | `AyCode.Core/AyCode.Core/docs/TOON/` |
|
||||
| `XCUT` | cross-cutting | Issues / TODOs spanning ≥2 ACCORE topics — one canonical home, referenced from each affected topic | `AyCode.Core/AyCode.Core/docs/XCUT/` |
|
||||
| `META` | meta-tooling | Issues/TODOs **about the workspace meta-tooling itself**: skills, registries, `.github/` conventions, ADR/Decision Log governance, protocol-stack edge cases. Distinct from code-domain topics (LOG, BIN, etc.). | `AyCode.Core/.github/META_ISSUES.md` + `AyCode.Core/.github/META_TODO.md` |
|
||||
| `LLMP` | LLM-protocol meta | LLM protocol decisions (Decision Log entries only — uses `LLMP-DEC-N` form, no prefix) | `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` |
|
||||
|
||||
## Type codes (universal across all repos)
|
||||
|
||||
| Code | Type | Used in file | Notes |
|
||||
|-------|----------------|----------------------------------------------|----------------------------------------------------------------------------------------------------|
|
||||
| `I` | Issue | `{TOPIC}_ISSUES.md` | Concrete concern: spec inconsistency, broken contract, observable edge case |
|
||||
| `T` | TODO | `{TOPIC}_TODO.md` | Forward-looking planned work: refactor, missing feature, optimization |
|
||||
| `B` | Bug | `{TOPIC}_ISSUES.md` (alongside `I` entries) | Confirmed broken behaviour, reproducible, needs a code fix |
|
||||
| `C` | Critical | `{TOPIC}_ISSUES.md` or `{TOPIC}_TODO.md` | **Severity override** — emergency priority, supersedes `I`/`B`/`T` category; body explains type |
|
||||
| `DEC` | Decision | `LLM_PROTOCOL_DECISIONS.md` | **LLMP-only.** Append-only protocol decision entries. |
|
||||
|
||||
### Distinctions
|
||||
|
||||
- **I vs B**: Both tracked together in `_ISSUES.md`. Use `B` only when the behaviour is confirmed broken with a reproducer. `I` covers concerns, inconsistencies, doc drift, edge cases without an active bug.
|
||||
- **C (Critical)**: A severity flag, not a category. `ACCORE-LOG-C-K7M2` means "AyCode.Core's logger critical item with random suffix K7M2" — body must state whether it's an underlying bug / issue / todo. Prefer `C` over `I`/`B`/`T` when severity is emergency. Do NOT double-classify (no `ACCORE-LOG-IB-K7M2` or similar).
|
||||
- **DEC**: LLMP exception — long form because "LLMP-D-1" is unreadable. Decision Log entries only.
|
||||
|
||||
## Per-repo extension convention
|
||||
|
||||
Each higher-layer repo MAY host its own `TOPIC_CODES.md` for repo-specific topics. Recommended location:
|
||||
|
||||
```
|
||||
<repo>/.github/TOPIC_CODES.md
|
||||
```
|
||||
|
||||
This per-repo file lists ONLY that repo's own topic codes. Lower-layer (inherited) topics are reachable through the dependency tree — at runtime, the `docs-check` skill walks `own-dep-repos` from the invocation point to gather both this repo's own topics AND all inherited topics from deps.
|
||||
|
||||
Topic codes need NOT be globally unique across repos — the `<PREFIX>` component disambiguates. Two repos may legitimately use the same topic code for repo-local concepts (e.g., one framework's `DAL` ≠ another framework's `DAL`).
|
||||
|
||||
If a higher-layer repo has no repo-specific topics, the file is omitted (default = the repo uses only inherited topics from its deps).
|
||||
|
||||
The framework (this file) does NOT enumerate higher-layer topics — that would violate Framework-First. To find all topics workspace-wide, agents walk the dep tree from a top-layer consumer (which transitively sees everything).
|
||||
|
||||
## ID format rules
|
||||
|
||||
1. **Format**: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — all uppercase, hyphen-separated. The `<PREFIX>` component identifies the owning repo per `AyCode.Core/.github/REPO_PREFIXES.md` (and per each repo's own `@repo.prefix` field). **LLMP exception**: `LLMP-DEC-N` entries (workspace-meta Decision Log) skip the prefix and use sequential `N` instead of `<RAND>` — single-file serialization avoids parallel-branch collision.
|
||||
2. **Random suffix**: `<RAND>` is a 4-character alphanumeric suffix from `[A-Z0-9]` (~1.7M combinations per `<PREFIX>-<TOPIC>-<TYPE>` triple). Generated at entry creation; the agent globs existing entries (active topic file + all year-bucketed archive files) and verifies uniqueness; regenerate on rare collision.
|
||||
3. **Append-only**: once assigned, IDs never change. If an entry is reversed or superseded, add a NEW entry that references the prior one — do not renumber, do not re-randomize.
|
||||
4. **Hash anchor** (markdown cross-file refs): lowercase with hyphens preserved (`LOGGING_ISSUES.md#accore-log-i-k7m2` — GitHub auto-converts). Always use the full prefixed form; bare hash anchors without prefix are ambiguous across repos.
|
||||
5. **No sub-category in ID**: legacy sub-prefixes like `PROTO-`, `DISPATCH-`, `CONN-`, `DS-` are NOT allowed at ID level. Capture sub-category in the entry body header: `## ACCORE-SIG-I-K7M2 [PROTO]: ...`.
|
||||
|
||||
## Registry maintenance — adding a new ACCORE topic
|
||||
|
||||
To add a new topic code **for AyCode.Core specifically**:
|
||||
1. Propose the code (2-6 uppercase chars), short and mnemonic, scoped to ACCORE's domain (framework concerns only).
|
||||
2. Check it doesn't collide with C# class-name prefixes (`Ac*` / `Mg*`) — the topic code should be visually distinct in mixed code/markdown content.
|
||||
3. Check it doesn't collide with existing ACCORE topic codes in the table above.
|
||||
4. Add a row to the "Framework's own (ACCORE) topic codes" table.
|
||||
5. Create the topic folder: `AyCode.Core/<project>/docs/{TOPIC_FOLDER_NAME}/` with `README.md`, optional `{TOPIC_FOLDER_NAME}_ISSUES.md`, `{TOPIC_FOLDER_NAME}_TODO.md`.
|
||||
6. Add a Decision Log entry (`LLMP-DEC-N`, in the workspace-level `LLM_PROTOCOL_DECISIONS.md`) recording the new framework topic.
|
||||
|
||||
For higher-layer repos: each consumer registers its own topics in its own `TOPIC_CODES.md` per the per-repo extension convention. No framework-level approval is needed — the consumer is sovereign over its own domain.
|
||||
|
||||
## Collision avoidance with class-name prefixes
|
||||
|
||||
C# code conventions in this workspace:
|
||||
- `Ac*` — AyCode.Core framework types (e.g., `AcLoggerBase`, `AcBinarySerializer`)
|
||||
- `Mg*` — Mango company types (e.g., `MgGrid`, `MgDbTableBase`, `MgEntityBase`)
|
||||
|
||||
Topic codes intentionally avoid these 2-char prefixes (`Ac`, `Mg`) to prevent visual confusion in mixed content. Topic codes are 2-6 chars and SHOULD NOT start with `Ac` or `Mg`. (Example principle: a hypothetical 2-char `MG` topic code would visually collide with `Mg*` class names; choose a more distinctive ≥3-char code.)
|
||||
|
||||
## Examples (ACCORE only)
|
||||
|
||||
```
|
||||
ACCORE-LOG-I-K7M2 # framework's logger issue (random suffix K7M2)
|
||||
ACCORE-LOG-T-3R9P # framework's logger TODO
|
||||
ACCORE-LOG-B-A4B7 # framework's logger bug (confirmed broken)
|
||||
ACCORE-LOG-C-X9Q4 # framework's logger CRITICAL — body: underlying bug / issue / todo
|
||||
ACCORE-SIG-I-M2K8 # framework's SignalR issue (body may note: [PROTO] sub-category)
|
||||
ACCORE-SBP-T-7N3F # framework's SignalR Binary Protocol TODO
|
||||
ACCORE-BIN-B-P5W2 # framework's Binary serializer bug
|
||||
ACCORE-TOON-I-D8R6 # framework's Toon issue
|
||||
ACCORE-XCUT-I-F4G1 # framework's cross-cutting issue (affects ≥2 ACCORE topics)
|
||||
LLMP-DEC-50 # workspace-meta Decision Log entry (no prefix — bare exception)
|
||||
```
|
||||
|
||||
The `<RAND>` suffixes shown above are illustrative. Real entries generate fresh random suffixes at creation time per `REPO_PREFIXES.md`'s "Random suffix spec".
|
||||
|
||||
## Cross-references to other files
|
||||
|
||||
- **Reference format** (cross-file in markdown): `LOGGING_ISSUES.md#accore-log-i-k7m2` (filename + lowercase hash anchor with full 4-component ID). Always use the full prefixed form — bare hash anchors without prefix are ambiguous across repos.
|
||||
- **Code comments**: `// See ACCORE-LOG-I-K7M2` — full prefixed form, since the ID is globally unique only with prefix.
|
||||
- **DB natural key** (future migration): `(prefix, topic, type, suffix)` tuple; or the full string `ACCORE-LOG-I-K7M2` as a single column.
|
||||
- **Workspace registries**: `AyCode.Core/.github/REPO_PREFIXES.md` (framework prefix spec); this file (ACCORE topics + format spec); each higher-layer repo's own `.github/TOPIC_CODES.md` (consumer-side topics); `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` (LLMP-DEC entries, workspace-meta history).
|
||||
|
||||
## Status field conventions
|
||||
|
||||
Every entry in `_ISSUES.md`, `_TODO.md`, and `LLM_PROTOCOL_DECISIONS.md` SHOULD carry an explicit `Status` field. **3 allowed values**:
|
||||
|
||||
| Status | Meaning | Archive eligible? |
|
||||
|---|---|---|
|
||||
| `Open` | Active / unresolved (default for new entries); also used for documented-current-behaviour entries that must remain visible | No |
|
||||
| `InProgress` | Partial work in flight; some scope addressed but more remains | No |
|
||||
| `Closed` | Done — bug fixed, decision made (won't fix / superseded by another entry / accepted), TODO completed. The body of the entry explains *what happened* (date, ref, rationale). | Yes |
|
||||
|
||||
### Defaults
|
||||
|
||||
- New entries default to `Status: Open`.
|
||||
- For documented current-behaviour entries (accepted limitations / "by design" / "this is how it works"), use `Status: Open` with an optional body callout: `> **Note:** This entry documents accepted current behaviour — not scheduled for change.` These never archive (Open status).
|
||||
|
||||
### Update workflow
|
||||
|
||||
When status changes, update the `Status` line in-place. **This is the ONE exception to append-only** — the Status field is mutable; entry body / ID / Description remain immutable.
|
||||
|
||||
When marking `Closed`:
|
||||
1. **Format the Status line as** `Status: Closed (YYYY-MM-DD)` — the inline date is what `docs-archive` uses to determine the destination year-bucket.
|
||||
2. **Add a `### Resolution` sub-section** documenting the closure. **Strongly recommended** — without it, future readers (and the `docs-archive` skill on lookup) have no context for "what changed, why, where". Suggested fields:
|
||||
- **What:** one-line summary of the change.
|
||||
- **Where:** code reference (file/class/commit hash) or doc reference (ADR / PR).
|
||||
- **Why:** the rationale (fix / "won't fix because X" / "superseded by ACCORE-LOG-I-XXXX" / "accepted as-is").
|
||||
- Optional: scope, date if different from Status line, related entries.
|
||||
|
||||
The body carries the **nuance**; the Status field only signals archive-eligibility.
|
||||
|
||||
### Lifecycle: archive
|
||||
|
||||
`Closed` entries are eligible for rotation into year-bucketed archive files (`<file>_<year>.md`) via the `docs-archive` skill. Year derived from a date in the entry body. Archive operation is user-invoked — closed entries don't disappear automatically. See `AyCode.Core/.github/skills/docs-archive/SKILL.md`.
|
||||
|
||||
## Change history
|
||||
|
||||
See the Decision Log (`../../../LLM_PROTOCOL_DECISIONS.md`) for the introduction of this registry and future topic-code additions.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,75 @@
|
|||
# Repos under protocol-audit (framework-only registry)
|
||||
|
||||
Per the Framework-First Design Principle, this Layer 0 registry lists **only the framework's own files** participating in the AI AGENT CORE PROTOCOL. Consumer files (Layer 1+) are discovered at audit time via the invocation-point repo's `own-dep-repos` walk — see `## Cross-repo audit discovery (runtime)` below.
|
||||
|
||||
## Canonical protocol host
|
||||
|
||||
**`AyCode.Core`** — this repo hosts the shared agent skills (`.github/skills/`), the Decision Log (`.github/LLM_PROTOCOL_DECISIONS.md`), and these registry files. All inherit files reference AyCode.Core. Cross-cutting invariants (X1–X3) are skipped for the host itself (it does not cross-reference itself).
|
||||
|
||||
If the host designation is ever moved to a different repo, update this section AND the inherit-file substring checked by invariant I1 in `SKILL.md`.
|
||||
|
||||
## Framework's own primary protocol files
|
||||
|
||||
| # | Name | Absolute path | Layer | Host |
|
||||
|---|-------------|---------------------------------------------|---------------|------|
|
||||
| 1 | AyCode.Core | `H:\Applications\Aycode\Source\AyCode.Core` | framework (0) | ★ |
|
||||
|
||||
The instruction file is at `<abs-path>\.github\copilot-instructions.md`.
|
||||
|
||||
## Cross-repo audit discovery (runtime)
|
||||
|
||||
When `protocol-audit` is invoked from a higher-layer repo (Layer 1+), the skill discovers participating files by walking the invocation-point repo's `own-dep-repos` recursively:
|
||||
|
||||
1. Read the invocation-point repo's `.github/copilot-instructions.md` `@repo` block.
|
||||
2. For each `own-dep-repos` entry, resolve the path relative to the repo root and read that dep's `@repo` block.
|
||||
3. Continue transitively until no new deps are found.
|
||||
4. Audit set = {invocation-point repo} ∪ {all walked deps}.
|
||||
|
||||
Effective audit scope per invocation:
|
||||
- From `AyCode.Core` (Layer 0) → audits only `AyCode.Core` (this file's table).
|
||||
- From `AyCode.Blazor` (Layer 1) → audits `AyCode.Core + AyCode.Blazor`.
|
||||
- From a Layer 2/3 consumer → audits the full transitive dep tree below it (consumer + all its deps).
|
||||
|
||||
The framework cannot directly enumerate consumer files (Framework-First). Higher layers naturally see Layer ≤ N — that's what `own-dep-repos` already encodes.
|
||||
|
||||
## File-type classification (by content, not by central registry)
|
||||
|
||||
Each discovered file is classified per content inspection:
|
||||
|
||||
- **Primary** — contains the `🛑 AI AGENT CORE PROTOCOL (CRITICAL ENFORCEMENT)` header → full invariant set applies (Common + Primary + Cross-cutting).
|
||||
- **Inherit** — contains the `follows the AI Agent Core Protocol defined in <HOST>` blockquote AND lacks the primary header → reduced invariant set applies (Common + Inherit + Cross-cutting).
|
||||
- **Unknown** — matches neither pattern → flag as `UNKNOWN` for manual review (do not silently skip).
|
||||
|
||||
See `SKILL.md` for the invariant sets.
|
||||
|
||||
## Invariants by type
|
||||
|
||||
**Primary files** — full invariant set per `SKILL.md`:
|
||||
- `@repo` block has all 5 required fields (`name`, `prefix`, `type`, `layer`, `own-dep-repos`); paths resolve to existing directories; `prefix` has valid format
|
||||
- Rule numbering contiguous 1..N; rule count ≥ 5
|
||||
- Rule #1 uses count+delta format
|
||||
- Rule #2 contains `CROSS-REPO HARD-GATE` and `PER-QUESTION DOC-FIRST`
|
||||
- Rule #3 is `STRICT NO-RE-READ POLICY (ANTI-LOOP)` and contains "in context" definition (`lossy compressions`)
|
||||
- Rule #4 contains auto-detection triggers
|
||||
- Rule #5 contains broad scope wording (`any file (code, documentation, configuration, memory, or otherwise)`)
|
||||
- `strictly maintain rule 3` reference exists
|
||||
- `## Shared Agent Skills` section with all three skills listed (X1)
|
||||
- `## Protocol History` section referencing `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` (X2)
|
||||
- Docs-sync rule references the `docs-check` skill (X3)
|
||||
|
||||
**Inherit files** — reduced invariant set:
|
||||
- `@repo` block (if present) has all 5 required fields; paths resolve; `prefix` has valid format
|
||||
- References AyCode.Core's protocol via substring: `follows the AI Agent Core Protocol defined in AyCode.Core` (I1)
|
||||
- Does NOT duplicate the numbered Rules #1-5 (I2)
|
||||
- Has a link to the Decision Log (I3)
|
||||
- Has `## Shared Agent Skills` section with all three skills listed (X1)
|
||||
- Has `## Protocol History` section referencing the canonical Decision Log (X2)
|
||||
- Numbered rules are NOT required (they are inherited from AyCode.Core)
|
||||
|
||||
## Known issues
|
||||
|
||||
*(No open issues.)*
|
||||
|
||||
## Maintenance note
|
||||
|
||||
This file lists only the framework's own files. When the framework's own repo set changes (rare — currently a single fixed entry), update the table above. **Consumer participation is auto-discovered** at audit time via `own-dep-repos` walking — no central enumeration of consumer repos is needed here, in line with the Framework-First Design Principle.
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// BDN benchmark mirroring the Console app's "F" menu (<c>SerializerSelectionMode.FastestByte</c>) —
|
||||
/// the focused 1:1 comparison between <b>AcBinary FastMode Byte[]</b> and <b>MemoryPack Default Byte[]</b>
|
||||
/// across the 5 production-shaped test data cells (Small / Medium / Large / Repeated / Deep).
|
||||
///
|
||||
/// <para>Why this exists: the Console app's adaptive measurement engine gives fast turnaround but is
|
||||
/// noise-prone; BDN's warmup + iteration + outlier-removal stack tightens the inter-engine delta to
|
||||
/// the point where ~1-2% micro-optimizations become detectable. Both runners feed the SAME
|
||||
/// <see cref="ISerializerBenchmark"/>-implementing workload (<see cref="AcBinaryBenchmark{T}"/> /
|
||||
/// <see cref="MemoryPackBenchmark{T}"/>) — so the BDN numbers are directly comparable to Console's
|
||||
/// <c>Console.FullBenchmark_Release_*.LLM</c> rows, only with tighter confidence intervals.</para>
|
||||
///
|
||||
/// <para>Output: BDN writes its native artifacts to <c>Test_Benchmark_Results/Benchmark/BDN/</c> (set
|
||||
/// globally in <c>Program.cs</c> via <c>WithArtifactsPath</c>). <see cref="BdnSummaryAdapter"/> then
|
||||
/// translates the <see cref="BenchmarkDotNet.Reports.Summary"/> into <see cref="Reporting.BenchmarkResult"/>
|
||||
/// rows and emits the unified <c>Bdn.FullBenchmark_*.{log,LLM,output}</c> triplet next to Console's
|
||||
/// counterparts in <c>Test_Benchmark_Results/Benchmark/</c>.</para>
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
public class AcBinaryVsMemPackBenchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// The 5 TestData cells matching Console's <c>BenchmarkLayer.Core</c> set —
|
||||
/// Small (2x2x2x2) / Medium (3x3x3x4) / Large (5x5x5x10) / Repeated (10 items) / Deep (2x4x4x8).
|
||||
/// Resolved at <see cref="GlobalSetup"/> time via <see cref="BenchmarkTestDataProvider_All_False.CreateTestDataSets"/>
|
||||
/// (same provider Console uses) so the workload graphs are bit-for-bit identical.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> TestDataNames => new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||||
|
||||
[ParamsSource(nameof(TestDataNames))]
|
||||
public string TestData { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Engine axis: AcBinary FastMode + Compact wire (UTF-8) vs MemoryPack Default (UTF-8). Compact-on-both-sides
|
||||
/// keeps the string-encoding dimension constant so the comparison reflects engine differences only.
|
||||
/// </summary>
|
||||
[Params("AcBinary", "MemoryPack")]
|
||||
public string Engine { get; set; } = "";
|
||||
|
||||
private ISerializerBenchmark _serializer = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
// BDN runs each benchmark in an isolated child process — the parent's charset selection (a static
|
||||
// field) does NOT cross the process boundary, so the child would otherwise fall back to the
|
||||
// compile-time default (Latin1Long). Pin the BDN serializer benchmark to Latin1Short here so its
|
||||
// cells line up with the Console Latin1Short runs. (Mirrored in BdnSummaryAdapter.WriteResults
|
||||
// for the parent process — .LLM charset label + Size(B) column.)
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
|
||||
|
||||
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var testDataSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith(TestData));
|
||||
|
||||
if (Engine == "AcBinary")
|
||||
{
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
_serializer = new AcBinaryBenchmark<TestOrder_All_False>(testDataSet.Order, options, "FastMode");
|
||||
}
|
||||
else
|
||||
{
|
||||
// MemoryPack's wire-mode-aligned ctor — Compact ↔ UTF-8 default for apples-to-apples vs AcBinary Compact.
|
||||
_serializer = new MemoryPackBenchmark<TestOrder_All_False>(testDataSet.Order, WireMode.Compact, "Default");
|
||||
}
|
||||
|
||||
// Round-trip correctness check before the BDN harness starts measuring — same gate the Console
|
||||
// runner enforces. Fails the run early if anything's broken (rather than producing meaningless numbers).
|
||||
if (!_serializer.VerifyRoundTrip())
|
||||
throw new InvalidOperationException($"Round-trip verification FAILED for {Engine} on {TestData}.");
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void Serialize() => _serializer.Serialize();
|
||||
|
||||
[Benchmark]
|
||||
public void Deserialize() => _serializer.Deserialize();
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..\AyCode.Core.targets" />
|
||||
|
||||
<!-- Exclude Test_Benchmark_Results from build to prevent path length issues -->
|
||||
<ItemGroup>
|
||||
<None Remove="Test_Benchmark_Results\**" />
|
||||
|
|
@ -17,6 +16,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
|
||||
<PackageReference Include="MemoryPack" Version="1.21.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.6.37110.2" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
using AyCode.Core.Benchmarks.Reporting;
|
||||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Reports;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Translates <see cref="BenchmarkDotNet.Reports.Summary"/> (BDN's post-run aggregate) into the unified
|
||||
/// <see cref="BenchmarkResult"/> rows consumed by <see cref="BenchmarkReportWriter"/>, then emits the
|
||||
/// <c>Bdn.FullBenchmark_*.{log,LLM,output}</c> triplet alongside Console's counterparts in
|
||||
/// <see cref="ReportingContext.ResolveResultsDirectory"/>'s <c>Test_Benchmark_Results/Benchmark/</c>.
|
||||
///
|
||||
/// <para><b>Why a separate adapter</b>: BDN's Summary is per-method (Serialize / Deserialize as separate
|
||||
/// <see cref="BenchmarkReport"/>s, parameterised by TestData + Engine). The unified format collapses these
|
||||
/// into per-cell rows (one row per <c>TestData × Engine</c> with both Ser and Des stats inline). The adapter
|
||||
/// groups, transposes, and converts ns → ms before handing off to the shared writer.</para>
|
||||
///
|
||||
/// <para><b>Mean vs Median</b>: maps BDN's <see cref="BenchmarkDotNet.Mathematics.Statistics.Median"/> into
|
||||
/// the BenchmarkResult's time columns — same convention as Console (which captures sample-median).
|
||||
/// Min/Max/StdDev populate the inter-sample range surfaced in <see cref="BenchmarkReportWriter.FormatMicrosWithRange"/>
|
||||
/// (incl. CV-warning ⚠️ marker when stddev/median exceeds <see cref="ReportingContext.UnstableCVThreshold"/>).</para>
|
||||
///
|
||||
/// <para><b>Iteration count = 1</b>: BDN reports <i>per-operation</i> time (ns) — already amortized across N
|
||||
/// invocations. The unified BenchmarkResult expects total-batch time + iteration count (so <c>µs/op =
|
||||
/// timeMs / iterations * 1000</c>). Storing Mean-in-ms with iterations = 1 makes the same formula yield
|
||||
/// Mean-in-µs directly. The actual BDN N count is recorded in the BDN-native artifacts (<c>.../BDN/...</c>)
|
||||
/// for anyone who wants the raw invocation count.</para>
|
||||
/// </summary>
|
||||
public static class BdnSummaryAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Post-run entry point — call once after <c>BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>(...)</c>
|
||||
/// returns. Produces the BDN-side <c>Bdn.*</c> file triplet AND prints the grouped-results console table
|
||||
/// (same view Console produces post-run) so the user sees the cell-level deltas immediately, without
|
||||
/// having to open the .LLM file.
|
||||
/// </summary>
|
||||
public static void WriteResults(Summary summary)
|
||||
{
|
||||
// Parent-process counterpart of AcBinaryVsMemPackBenchmark.Setup's charset pin: the BDN child
|
||||
// processes ran Latin1Short, but this adapter runs in the parent process where LongStringSuffix
|
||||
// would still be the compile-time default (Latin1Long). Set it so GetCharsetName() labels the
|
||||
// .LLM correctly AND the CreateTestDataSets()/CreateWorkload calls below compute matching Size(B).
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
|
||||
|
||||
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var results = Translate(summary, allTestData);
|
||||
var ctx = CreateContext();
|
||||
|
||||
BenchmarkReportWriter.PrintGroupedResults(results, allTestData);
|
||||
BenchmarkReportWriter.SaveAll(ctx, results, allTestData);
|
||||
}
|
||||
|
||||
private static ReportingContext CreateContext()
|
||||
{
|
||||
#if DEBUG
|
||||
const string buildConfig = "Debug";
|
||||
#elif SGEN_ONLY
|
||||
const string buildConfig = "SGenOnly";
|
||||
#else
|
||||
const string buildConfig = "Release";
|
||||
#endif
|
||||
return new ReportingContext(
|
||||
SourceTag: "Bdn",
|
||||
ResultsDirectory: ReportingContext.ResolveResultsDirectory(),
|
||||
BuildConfiguration: buildConfig,
|
||||
Utf8NoBom: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||
CharsetName: GetCharsetName(),
|
||||
// Warmup / Samples / TargetSampleMs are BDN-managed (not Console's adaptive engine). Zeros here
|
||||
// signal "BDN handled internally" in the header; the BDN-native artifacts under .../BDN/ have
|
||||
// the exact BDN config (warmup count, iteration count, run strategy) for anyone who needs it.
|
||||
WarmupIterations: 0,
|
||||
BenchmarkSamples: 0,
|
||||
TargetSampleMs: 0,
|
||||
UnstableCVThreshold: 0.03,
|
||||
MicroOptCVThreshold: 0.015);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the human-readable name for the currently-active <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
|
||||
/// charset. Mirrors Console's <c>Configuration.GetCurrentCharsetName</c>. The BDN serializer benchmark
|
||||
/// pins the charset to <c>Latin1Short</c> — set in <see cref="WriteResults"/> (parent process) and in
|
||||
/// <c>AcBinaryVsMemPackBenchmark.Setup</c> (child process); see those sites for the process-isolation
|
||||
/// rationale (BDN's per-benchmark child processes don't inherit a parent static-field mutation).
|
||||
/// </summary>
|
||||
private static string GetCharsetName()
|
||||
{
|
||||
var s = BenchmarkTestDataProvider.LongStringSuffix;
|
||||
return s switch
|
||||
{
|
||||
CharsetSuffixes.AsciiFix => nameof(CharsetSuffixes.AsciiFix),
|
||||
CharsetSuffixes.AsciiShort => nameof(CharsetSuffixes.AsciiShort),
|
||||
CharsetSuffixes.AsciiLong => nameof(CharsetSuffixes.AsciiLong),
|
||||
CharsetSuffixes.Latin1Fix => nameof(CharsetSuffixes.Latin1Fix),
|
||||
CharsetSuffixes.Latin1Short => nameof(CharsetSuffixes.Latin1Short),
|
||||
CharsetSuffixes.Latin1Long => nameof(CharsetSuffixes.Latin1Long),
|
||||
CharsetSuffixes.CjkBmpShort => nameof(CharsetSuffixes.CjkBmpShort),
|
||||
CharsetSuffixes.CjkBmpLong => nameof(CharsetSuffixes.CjkBmpLong),
|
||||
CharsetSuffixes.CyrillicShort => nameof(CharsetSuffixes.CyrillicShort),
|
||||
CharsetSuffixes.CyrillicLong => nameof(CharsetSuffixes.CyrillicLong),
|
||||
CharsetSuffixes.MixedShort => nameof(CharsetSuffixes.MixedShort),
|
||||
CharsetSuffixes.MixedLong => nameof(CharsetSuffixes.MixedLong),
|
||||
_ => "Custom"
|
||||
};
|
||||
}
|
||||
|
||||
private static List<BenchmarkResult> Translate(Summary summary, List<TestDataSet> allTestData)
|
||||
{
|
||||
var grouped = summary.Reports
|
||||
.Where(r => r.Success && r.ResultStatistics != null)
|
||||
.GroupBy(r => (
|
||||
TestData: GetParam(r, "TestData"),
|
||||
Engine: GetParam(r, "Engine")
|
||||
))
|
||||
.Where(g => !string.IsNullOrEmpty(g.Key.TestData) && !string.IsNullOrEmpty(g.Key.Engine))
|
||||
.ToList();
|
||||
|
||||
var results = new List<BenchmarkResult>(grouped.Count);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var testDataSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith(group.Key.TestData));
|
||||
var engineEnum = group.Key.Engine switch
|
||||
{
|
||||
"AcBinary" => BenchmarkEngine.AcBinary,
|
||||
"MemoryPack" => BenchmarkEngine.MemoryPack,
|
||||
_ => throw new InvalidOperationException($"Unknown engine in BDN params: {group.Key.Engine}")
|
||||
};
|
||||
|
||||
// Construct the same workload instance AcBinaryVsMemPackBenchmark.Setup would build — same options,
|
||||
// same wire mode. Reading SerializedSize + OptionsDescription from it keeps the BDN-side metadata
|
||||
// in lockstep with what the workload actually serialised (no drift between hardcoded BDN strings
|
||||
// and the workload's own OptionsDescription / SerializedSize).
|
||||
var workload = CreateWorkload(testDataSet, group.Key.Engine);
|
||||
|
||||
var result = new BenchmarkResult
|
||||
{
|
||||
TestDataName = testDataSet.DisplayName,
|
||||
Engine = engineEnum,
|
||||
IoMode = BenchmarkIoMode.ByteArray,
|
||||
DispatchMode = BenchmarkDispatchMode.SGen,
|
||||
OptionsPreset = group.Key.Engine == "AcBinary" ? "FastMode" : "Default",
|
||||
OrderTypeName = nameof(TestOrder_All_False),
|
||||
SerializedSize = workload.SerializedSize,
|
||||
OptionsDescription = workload.OptionsDescription,
|
||||
};
|
||||
|
||||
// ns → ms (BenchmarkResult expects ms per op with iter=1, so µs/op = ms * 1000 / 1 = ms*1000).
|
||||
const double nsToMs = 1.0 / 1_000_000.0;
|
||||
|
||||
foreach (var report in group)
|
||||
{
|
||||
var methodName = report.BenchmarkCase.Descriptor.WorkloadMethod.Name;
|
||||
var stats = report.ResultStatistics!;
|
||||
var allocBytes = report.GcStats.GetBytesAllocatedPerOperation(report.BenchmarkCase) ?? 0;
|
||||
|
||||
if (methodName == "Serialize")
|
||||
{
|
||||
result.SerializeTimeMs = stats.Median * nsToMs;
|
||||
result.SerializeTimeMinMs = stats.Min * nsToMs;
|
||||
result.SerializeTimeMaxMs = stats.Max * nsToMs;
|
||||
result.SerializeTimeStdDevMs = stats.StandardDeviation * nsToMs;
|
||||
result.SerializeIterations = 1; // see class-doc "Iteration count = 1" note
|
||||
result.SerializeAllocBytesPerOp = allocBytes;
|
||||
}
|
||||
else if (methodName == "Deserialize")
|
||||
{
|
||||
result.DeserializeTimeMs = stats.Median * nsToMs;
|
||||
result.DeserializeTimeMinMs = stats.Min * nsToMs;
|
||||
result.DeserializeTimeMaxMs = stats.Max * nsToMs;
|
||||
result.DeserializeTimeStdDevMs = stats.StandardDeviation * nsToMs;
|
||||
result.DeserializeIterations = 1;
|
||||
result.DeserializeAllocBytesPerOp = allocBytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Compose RT from Ser + Des per-op µs (same logic as Console BenchmarkLoop's in-memory
|
||||
// composition — since BDN measures Ser and Des independently, RT here is the analytic sum).
|
||||
var serPerOp = BenchmarkReportWriter.ToPerOpMicros(result.SerializeTimeMs, result.SerializeIterations);
|
||||
var desPerOp = BenchmarkReportWriter.ToPerOpMicros(result.DeserializeTimeMs, result.DeserializeIterations);
|
||||
var rtPerOp = serPerOp + desPerOp;
|
||||
result.RoundTripIterations = Math.Max(result.SerializeIterations, result.DeserializeIterations);
|
||||
result.RoundTripTimeMs = rtPerOp / 1000.0 * result.RoundTripIterations;
|
||||
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string GetParam(BenchmarkReport report, string name) =>
|
||||
report.BenchmarkCase.Parameters.Items.FirstOrDefault(p => p.Name == name)?.Value?.ToString() ?? "";
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the same workload instance <see cref="AcBinaryVsMemPackBenchmark.Setup"/> would build —
|
||||
/// same options, same wire mode. The adapter reads <see cref="ISerializerBenchmark.SerializedSize"/> and
|
||||
/// <see cref="ISerializerBenchmark.OptionsDescription"/> from this instance so the BDN-side BenchmarkResult
|
||||
/// rows carry the same workload-side metadata the Console rows have (no risk of drift between hardcoded
|
||||
/// adapter strings and what the workload actually used).
|
||||
///
|
||||
/// Cost: one Serialize call inside the ctor per (TestData × Engine) cell — runs once during summary
|
||||
/// translation, NOT in BDN's measured hot path. Negligible vs BDN's per-run cost.
|
||||
/// </summary>
|
||||
private static ISerializerBenchmark CreateWorkload(TestDataSet<TestOrder_All_False> testDataSet, string engine)
|
||||
{
|
||||
if (engine == "AcBinary")
|
||||
{
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
return new AcBinaryBenchmark<TestOrder_All_False>(testDataSet.Order, options, "FastMode");
|
||||
}
|
||||
return new MemoryPackBenchmark<TestOrder_All_False>(testDataSet.Order, WireMode.Compact, "Default");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +1,59 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using BenchmarkDotNet.Diagnosers;
|
||||
using BenchmarkDotNet.Jobs;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// JIT disassembly benchmark for AcBinarySerializer hot path analysis.
|
||||
/// Shows actual x64 assembly generated by the JIT to verify inlining decisions.
|
||||
/// Direct JIT-disassembly harness for the AcBinary <b>Large Serialize</b> hot path — the cell where
|
||||
/// the PGO-driven inline bistability shows up (~120 µs/op fast mode ⇄ ~142 µs/op slow mode, same
|
||||
/// source, run-to-run).
|
||||
///
|
||||
/// Usage: dotnet run -c Release -- --filter *JitDisassemblyBenchmark*
|
||||
/// Or from Program.cs: --jitasm
|
||||
/// <para><b>Not a BenchmarkDotNet benchmark.</b> BDN's <c>DisassemblyDiagnoser</c> produced no output
|
||||
/// ("No benchmarks were disassembled"); this harness leans on the runtime's own JIT disassembler
|
||||
/// instead. <see cref="Run"/> builds the workload and exercises the Large Ser FastMode path — when the
|
||||
/// process is launched with <c>DOTNET_JitDisasm=<pattern></c> the JIT dumps the x64 assembly of
|
||||
/// every matching method to stdout as it compiles them.</para>
|
||||
///
|
||||
/// Output: BenchmarkDotNet artifacts folder contains .asm files with full disassembly.
|
||||
/// Look for:
|
||||
/// - WritePropertyOrSkip / WritePropertyMarkerless: are they inlined or called?
|
||||
/// - WriteInt32 / WriteFloat64Unsafe / etc.: inlined into the caller or separate calls?
|
||||
/// - context parameter passing: register usage (RCX/RDX/R8/R9)
|
||||
/// <para><b>Run it</b> (via the <c>--jitasm</c> switch). Set:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DOTNET_TieredCompilation=0</c> — each method compiled once, straight to full-opt Tier-1:
|
||||
/// deterministic codegen, no tiering/PGO lottery (so the disasm is reproducible).</item>
|
||||
/// <item><c>DOTNET_JitDisasm=<pattern></c> — e.g. <c>*GeneratedWriter*</c> for the SGen writer
|
||||
/// hot loop. The un-inlined <c>call</c>s in that loop are the candidates for the PGO-flipped inline
|
||||
/// site; pinning the right callee with <c>[MethodImpl(AggressiveInlining)]</c> locks in the fast mode.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>The workload mirrors <see cref="AcBinaryVsMemPackBenchmark"/> exactly — Large (5×5×5×10)
|
||||
/// <see cref="TestOrder_All_False"/> graph, AsciiShort charset, FastMode + Compact wire.</para>
|
||||
/// </summary>
|
||||
[SimpleJob(RuntimeMoniker.Net90)]
|
||||
[DisassemblyDiagnoser(maxDepth: 4, printSource: true, exportGithubMarkdown: true)]
|
||||
[MemoryDiagnoser(displayGenColumns: false)]
|
||||
public class JitDisassemblyBenchmark
|
||||
public sealed class JitDisassemblyBenchmark
|
||||
{
|
||||
private TestOrder _order = null!;
|
||||
private AcBinarySerializerOptions _fastModeOptions = null!;
|
||||
private AcBinarySerializerOptions _defaultOptions = null!;
|
||||
private byte[] _serializedFastMode = null!;
|
||||
private byte[] _serializedDefault = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
|
||||
// Medium data: enough properties to show loop behavior, not too large for disassembly
|
||||
_order = TestDataFactory.CreateOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 4,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser);
|
||||
|
||||
_fastModeOptions = AcBinarySerializerOptions.FastMode;
|
||||
_defaultOptions = AcBinarySerializerOptions.Default;
|
||||
_serializedFastMode = AcBinarySerializer.Serialize(_order, _fastModeOptions);
|
||||
_serializedDefault = AcBinarySerializer.Serialize(_order, _defaultOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FastMode serialize — no ref tracking, no string interning.
|
||||
/// Builds the Large workload and JITs + exercises the Large Ser FastMode hot path. With
|
||||
/// <c>DOTNET_JitDisasm</c> set, the JIT emits the matching methods' disassembly to stdout on
|
||||
/// first compile; the loop guarantees every reachable serializer method is JIT-compiled (and,
|
||||
/// if tiering is left on, promoted to Tier-1).
|
||||
/// </summary>
|
||||
[Benchmark(Baseline = true)]
|
||||
public byte[] Serialize_FastMode()
|
||||
public void Run()
|
||||
{
|
||||
return AcBinarySerializer.Serialize(_order, _fastModeOptions);
|
||||
}
|
||||
// Mirror AcBinaryVsMemPackBenchmark exactly: AsciiShort charset (where the Large-Ser bimodality
|
||||
// was observed), Large (5×5×5×10) TestOrder_All_False graph, FastMode + Compact wire.
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiShort;
|
||||
|
||||
/// <summary>
|
||||
/// FastMode deserialize.
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public TestOrder Deserialize_FastMode()
|
||||
{
|
||||
return AcBinaryDeserializer.Deserialize<TestOrder>(_serializedFastMode, _fastModeOptions);
|
||||
}
|
||||
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var largeSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith("Large"));
|
||||
var order = largeSet.Order;
|
||||
|
||||
/// <summary>
|
||||
/// Default serialize — ref tracking + string interning (scan pass + write pass).
|
||||
/// Shows IdentityMap lookup overhead in hot path.
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public byte[] Serialize_Default()
|
||||
{
|
||||
return AcBinarySerializer.Serialize(_order, _defaultOptions);
|
||||
}
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
|
||||
/// <summary>
|
||||
/// Default deserialize — ref tracking + string interning.
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public TestOrder Deserialize_Default()
|
||||
{
|
||||
return AcBinaryDeserializer.Deserialize<TestOrder>(_serializedDefault, _defaultOptions);
|
||||
Console.WriteLine("=== JIT-DISASM HARNESS: Large Ser FastMode (TestOrder_All_False, AsciiShort) — start ===");
|
||||
|
||||
byte[] last = null!;
|
||||
for (var i = 0; i < 50; i++)
|
||||
last = AcBinarySerializer.Serialize(order, options);
|
||||
|
||||
Console.WriteLine($"=== JIT-DISASM HARNESS: done — 50 Large Ser ops, last payload {last.Length} bytes ===");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,9 +70,15 @@ namespace AyCode.Benchmark
|
|||
return;
|
||||
}
|
||||
|
||||
// Configure BenchmarkDotNet to write artifacts into the centralized benchmark directory
|
||||
// BDN-native artifacts go under <results>/Benchmark/BDN/ (per the unified output convention —
|
||||
// see ReportingContext docs). The unified Bdn.FullBenchmark_*.{log,LLM,output} triplet (emitted
|
||||
// by BdnSummaryAdapter after BDN finishes) lands one level up in <results>/Benchmark/, next to
|
||||
// the Console.*.* counterparts produced by the Console runner.
|
||||
var bdnArtifactsDir = Path.Combine(benchmarkDir, "BDN");
|
||||
Directory.CreateDirectory(bdnArtifactsDir);
|
||||
|
||||
var config = ManualConfig.Create(DefaultConfig.Instance)
|
||||
.WithArtifactsPath(benchmarkDir);
|
||||
.WithArtifactsPath(bdnArtifactsDir);
|
||||
|
||||
if (args.Length > 0 && args[0] == "--quick")
|
||||
{
|
||||
|
|
@ -94,39 +100,27 @@ namespace AyCode.Benchmark
|
|||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--minimal")
|
||||
if (args.Length > 0 && args[0] == "--serializers")
|
||||
{
|
||||
RunBenchmark<MinimalBenchmark>(config, benchmarkDir, memDiagDir, "MinimalBenchmark");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--simple")
|
||||
{
|
||||
RunBenchmark<SimpleBinaryBenchmark>(config, benchmarkDir, memDiagDir, "SimpleBinaryBenchmark");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--complex")
|
||||
{
|
||||
RunBenchmark<ComplexBinaryBenchmark>(config, benchmarkDir, memDiagDir, "ComplexBinaryBenchmark");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--msgpack")
|
||||
{
|
||||
RunBenchmark<MessagePackComparisonBenchmark>(config, benchmarkDir, memDiagDir, "MessagePackComparisonBenchmark");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--sizes")
|
||||
{
|
||||
RunSizeComparison();
|
||||
// Unified serializer benchmark mirroring Console's "F" menu (FastestByte) — AcBinary FastMode
|
||||
// Byte[] vs MemoryPack Default Byte[] across 5 TestData cells. BdnSummaryAdapter translates
|
||||
// the BDN Summary into BenchmarkResult rows and emits the Bdn.FullBenchmark_*.{log,LLM,output}
|
||||
// triplet to <results>/Benchmark/ (BDN-native artifacts go under .../BDN/ via the global config).
|
||||
WithProcessStabilization(() =>
|
||||
{
|
||||
var serializerSummary = BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>(config);
|
||||
BdnSummaryAdapter.WriteResults(serializerSummary);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--jitasm")
|
||||
{
|
||||
RunBenchmark<JitDisassemblyBenchmark>(config, benchmarkDir, memDiagDir, "JitDisassemblyBenchmark");
|
||||
// Direct JIT-disasm harness — NOT BenchmarkDotNet. BDN's DisassemblyDiagnoser produced
|
||||
// nothing here ("No benchmarks were disassembled"); this leans on the runtime's own JIT
|
||||
// disassembler instead. Launch with DOTNET_TieredCompilation=0 + DOTNET_JitDisasm=<pattern>
|
||||
// (e.g. *GeneratedWriter*) — the JIT dumps the matching methods' x64 asm to stdout.
|
||||
new JitDisassemblyBenchmark().Run();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,24 +128,74 @@ namespace AyCode.Benchmark
|
|||
Console.WriteLine(" --quick Quick benchmark with tabular output (AcBinary vs MessagePack)");
|
||||
Console.WriteLine(" --test Quick AcBinary test");
|
||||
Console.WriteLine(" --testmsgpack Quick MessagePack test");
|
||||
Console.WriteLine(" --minimal Minimal benchmark");
|
||||
Console.WriteLine(" --simple Simple flat object benchmark");
|
||||
Console.WriteLine(" --complex Complex hierarchy (AcBinary vs JSON)");
|
||||
Console.WriteLine(" --msgpack MessagePack comparison");
|
||||
Console.WriteLine(" --sizes Size comparison only");
|
||||
Console.WriteLine(" --serializers AcBinary FastMode vs MemoryPack Default across 5 test data cells (mirrors Console F menu)");
|
||||
Console.WriteLine(" --jitasm JIT disassembly analysis (shows actual x64 assembly for hot path)");
|
||||
Console.WriteLine(" --save-coverage <file> Save coverage file into Test_Benchmark_Results/CoverageReport");
|
||||
|
||||
if (args.Length == 0)
|
||||
// Default path: hand control to BDN's BenchmarkSwitcher (no args → interactive picker; with
|
||||
// args → BDN parses them as benchmark filters / job options). Same code path either way — the
|
||||
// known custom switches above (--serializers, --jitasm, --quick, --test, --testmsgpack,
|
||||
// --save-coverage) return early before reaching this point.
|
||||
WithProcessStabilization(() =>
|
||||
{
|
||||
BenchmarkSwitcher.FromAssembly(typeof(MinimalBenchmark).Assembly).Run(args, config);
|
||||
// Collect artifacts after running switcher
|
||||
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
|
||||
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the given action with CPU affinity pinned to CPU 0 and process priority raised to High,
|
||||
/// restoring the original state in <c>finally</c>. Matches the stabilization block in
|
||||
/// <c>AyCode.Core.Serializers.Console.BenchmarkLoop.RunBenchmark</c> so BDN-side measurements
|
||||
/// receive the same OS-scheduler insulation the Console runner enjoys.
|
||||
/// <para><b>Worker process inheritance:</b> BDN spawns a per-job child process to host the
|
||||
/// workload. CPU affinity propagates from parent → child on both Windows (CreateProcess inherits
|
||||
/// affinity by default) and Linux (fork+exec inherits via sched_setaffinity). So pinning the
|
||||
/// orchestrator process here pins the actual measurement loop too — not just the BDN driver.</para>
|
||||
/// <para>Skipped on macOS where <see cref="Process.ProcessorAffinity"/> throws (priority still
|
||||
/// raised). Failures inside the try block fall through to a best-effort restore in finally.</para>
|
||||
/// </summary>
|
||||
static void WithProcessStabilization(Action action)
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
var origAffinity = (IntPtr)0;
|
||||
var origPriority = ProcessPriorityClass.Normal;
|
||||
var stabilizationApplied = false;
|
||||
|
||||
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
|
||||
{
|
||||
try
|
||||
{
|
||||
origAffinity = process.ProcessorAffinity;
|
||||
origPriority = process.PriorityClass;
|
||||
// Pin to CPU 0 (mask = 1). The choice is arbitrary — what matters is "exactly one
|
||||
// core, consistently" — not which one. BDN's child worker process inherits the
|
||||
// affinity, so the measurement loop itself runs pinned. Mirrors Console's pinning.
|
||||
process.ProcessorAffinity = (IntPtr)1;
|
||||
process.PriorityClass = ProcessPriorityClass.High;
|
||||
stabilizationApplied = true;
|
||||
Console.WriteLine("Stabilization: pinned to CPU 0 (affinity=0x1), priority=High (BDN workers inherit affinity).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Affinity / priority changes may fail on locked-down hosts (group policies, containers
|
||||
// without CAP_SYS_NICE on Linux). Surface and continue — BDN still works, just without
|
||||
// scheduler insulation.
|
||||
Console.WriteLine($"Stabilization SKIPPED: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
try
|
||||
{
|
||||
BenchmarkSwitcher.FromAssembly(typeof(MinimalBenchmark).Assembly).Run(args, config);
|
||||
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
|
||||
{
|
||||
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
|
||||
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +213,7 @@ namespace AyCode.Benchmark
|
|||
|
||||
// Create test data with shared references
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
|
|
@ -247,19 +291,19 @@ namespace AyCode.Benchmark
|
|||
// AcBinary WithRef Deserialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
|
||||
var acWithRefDeserialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// AcBinary NoRef Deserialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
|
||||
var acNoRefDeserialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// MessagePack Deserialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, msgPackOptions);
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, msgPackOptions);
|
||||
var msgPackDeserialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
results.Add(("Deserialize", "WithRef", acWithRefDeserialize, msgPackDeserialize));
|
||||
|
|
@ -280,7 +324,7 @@ namespace AyCode.Benchmark
|
|||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var target = CreatePopulateTarget(testOrder);
|
||||
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef.AsSpan(), target);
|
||||
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef, target);
|
||||
}
|
||||
var acMerge = sw.Elapsed.TotalMilliseconds;
|
||||
results.Add(("Merge", "NoRef", acMerge, 0));
|
||||
|
|
@ -332,12 +376,12 @@ namespace AyCode.Benchmark
|
|||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static TestOrder CreatePopulateTarget(TestOrder source)
|
||||
static TestOrder_All_True CreatePopulateTarget(TestOrder_All_True source)
|
||||
{
|
||||
var target = new TestOrder { Id = source.Id };
|
||||
var target = new TestOrder_All_True { Id = source.Id };
|
||||
foreach (var item in source.Items)
|
||||
{
|
||||
target.Items.Add(new TestOrderItem { Id = item.Id });
|
||||
target.Items.Add(new TestOrderItem_All_True { Id = item.Id });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,129 @@
|
|||
# AyCode.Benchmark
|
||||
|
||||
BenchmarkDotNet-based performance benchmarking console app. Compares AcBinary serializer against MessagePack, BSON, and JSON across various scenarios.
|
||||
BenchmarkDotNet performance suite **plus** the shared workload / reporting infrastructure used by both BDN and the Console runner. Targets .NET 9.
|
||||
|
||||
## Key Files
|
||||
## Role: dual-purpose project
|
||||
|
||||
- **`Program.cs`** — CLI entry point with `--quick`, `--test`, `--minimal`, `--simple`, `--complex`, `--msgpack`, `--sizes`, `--jitasm` modes. Collects results to `Test_Benchmark_Results/` at solution root.
|
||||
- **`SerializationBenchmarks.cs`** — Primary suite: MinimalBenchmark, SimpleBinaryBenchmark, ComplexBinaryBenchmark, MessagePackComparisonBenchmark, AcBinaryVsMessagePackFullBenchmark, SizeComparisonBenchmark, LargeScaleBenchmark (~25K objects), AcJsonVsSystemTextJsonBenchmark.
|
||||
- **`SourceGeneratorBenchmarks.cs`** — Source-generated vs runtime reflection serializers. Includes PureContractlessBenchmark, SourceGeneratorVsRuntimeBenchmark, RepeatedStringBenchmark (string interning).
|
||||
- **`SignalRCommunicationBenchmarks.cs`** — Full-stack SignalR message performance: client creation → MessagePack serialization → server deserialization → response → round-trip.
|
||||
- **`SignalRRoundTripBenchmarks.cs`** — Real SignalR infrastructure benchmarks: primitives, complex objects, collections, mixed parameters.
|
||||
- **`JitDisassemblyBenchmark.cs`** — JIT analysis: generates .asm files to verify inlining decisions on serialize/deserialize hot paths.
|
||||
- **`TaskHelperBenchmarks.cs`** — Task/timing utilities: WaitToAsync, ThreadPool (custom vs Task.Run), timing methods (UtcNow.Ticks vs TickCount64).
|
||||
- **`RefForeachBenchmark.cs`** — Collection iteration patterns: array vs list, foreach vs index, ref readonly vs by-value for large structs.
|
||||
- **`ValueTypePassingBenchmark.cs`** — Copy-by-value vs `in` parameter for 16-byte types (Decimal, DateTimeOffset, Guid).
|
||||
This project plays **two roles**:
|
||||
|
||||
1. **BDN runner Exe** — standalone benchmark host (`Program.cs` + `[Benchmark]`-decorated classes). Invoke via `dotnet run -c Release --project AyCode.Benchmark -- <switch>`.
|
||||
2. **Shared workload + reporting library** — exposes `public` types under [`Workloads/Scenarios/`](Workloads/Scenarios/) and [`Reporting/`](Reporting/) that [`AyCode.Core.Serializers.Console`](../AyCode.Core.Serializers.Console/README.md) consumes via `<ProjectReference>`.
|
||||
|
||||
Both runners feed the SAME `ISerializerBenchmark` workload (same test data graphs, same wire options, same payload sizes) — so Console's adaptive-engine numbers and BDN's iteration-based numbers are **directly comparable**.
|
||||
|
||||
## Output convention
|
||||
|
||||
Both runners emit a unified `.log` / `.LLM` / `.output` triplet to `Test_Benchmark_Results/Benchmark/` (resolved at runtime via walk-up to the nearest `AyCode.Core.sln` — worktree-aware):
|
||||
|
||||
| File | Source | Content |
|
||||
|---|---|---|
|
||||
| `Console.FullBenchmark_<Build>_<ts>.log` | Console runner | Human-readable formatted view |
|
||||
| `Console.FullBenchmark_<Build>_<ts>.LLM` | Console runner | Markdown table, LLM-paste-friendly |
|
||||
| `Console.FullBenchmark_<Build>_<ts>.output` | Console runner | Hex dump of Large cell binary |
|
||||
| `Bdn.FullBenchmark_<Build>_<ts>.log` | BDN runner | Same format as Console |
|
||||
| `Bdn.FullBenchmark_<Build>_<ts>.LLM` | BDN runner | Same |
|
||||
| `Bdn.FullBenchmark_<Build>_<ts>.output` | BDN runner | Same |
|
||||
|
||||
BDN-native artifacts (BDN's own reports, raw measurements, run logs) go to `Test_Benchmark_Results/Benchmark/BDN/` — kept separate so the unified Console+BDN `.log/.LLM/.output` triplet stays uncluttered.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ AyCode.Benchmark (this project) │
|
||||
│ │
|
||||
│ Workloads/Scenarios/ public — shared workload types │
|
||||
│ ISerializerBenchmark, BenchmarkOptions, BenchmarkEnums, │
|
||||
│ AcBinaryBenchmark<T>, MemoryPackBenchmark<T>, │
|
||||
│ AcBinaryBufferWriterBenchmark<T>, ... (12 concretes), │
|
||||
│ RoundTripValidator │
|
||||
│ │
|
||||
│ Reporting/ public — shared reporting types │
|
||||
│ BenchmarkResult, ReportingContext, BenchmarkReportWriter │
|
||||
│ │
|
||||
│ AcBinaryVsMemPackBenchmark.cs BDN [Benchmark] class │
|
||||
│ (mirrors Console "F" menu) │
|
||||
│ BdnSummaryAdapter.cs Summary → BenchmarkResult → │
|
||||
│ BenchmarkReportWriter.SaveAll │
|
||||
│ Program.cs BDN entry + CLI dispatch │
|
||||
│ │
|
||||
│ + KEEP: JitDisassemblyBenchmark, RefForeachBenchmark, │
|
||||
│ TaskHelperBenchmarks, ValueTypePassingBenchmark, │
|
||||
│ SourceGeneratorBenchmarks, │
|
||||
│ SignalRCommunicationBenchmarks, │
|
||||
│ SignalRRoundTripBenchmarks │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ ProjectReference (one-way)
|
||||
│
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ AyCode.Core.Serializers.Console │
|
||||
│ │
|
||||
│ BenchmarkLoop.cs custom adaptive measure engine │
|
||||
│ (CPU 0 pin, High priority, phase-isolated warmup, │
|
||||
│ 10-sample median + pilot, ~250ms/cell calibration) │
|
||||
│ Menu.cs / Configuration.cs / Program.cs Console UX │
|
||||
│ │
|
||||
│ Uses Benchmark's: │
|
||||
│ - Workloads/Scenarios/* (interface + concrete benchmarks) │
|
||||
│ - Reporting/BenchmarkReportWriter (SaveAll, Print...) │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Two runners — same workload, different measurement engines
|
||||
|
||||
| Aspect | Console (custom engine) | BDN |
|
||||
|---|---|---|
|
||||
| Use case | Fast iteration during micro-opt loops | Statistically confident before-commit validation |
|
||||
| Measurement | Adaptive per-cell iter (target ~250ms), 10 samples + pilot, median | Warmup + N iterations, outlier removal, JIT-stabilized, process-spawn isolation |
|
||||
| Time per full run | ~1-3 min | ~5-15 min |
|
||||
| Noise floor | ~3-5% inter-engine delta visible | ~1-2% |
|
||||
| Output format | Identical (same `BenchmarkReportWriter` writes both) | |
|
||||
|
||||
The Console and BDN outputs use the SAME `BenchmarkResult` DTO and the SAME formatter, so cells are directly comparable: pick a cell in `Console.FullBenchmark_*.LLM`, find the same cell in `Bdn.FullBenchmark_*.LLM` — deltas should agree within BDN's tighter CI.
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
dotnet run -c Release --project AyCode.Benchmark -- <switch>
|
||||
```
|
||||
|
||||
| Switch | Description |
|
||||
|---|---|
|
||||
| `--serializers` | AcBinary FastMode Byte[] vs MemoryPack Default Byte[] across 5 TestData cells (mirrors Console "F" menu / FastestByte). Emits `Bdn.FullBenchmark_*.{log,LLM,output}` + BDN-native artifacts under `BDN/`. |
|
||||
| `--jitasm` | JIT disassembly analysis (x64 asm of serialize/deserialize hot path). |
|
||||
| `--quick` | Quick inline benchmark (custom Stopwatch-based, not BDN). |
|
||||
| `--test` / `--testmsgpack` | Quick smoke tests. |
|
||||
| `--save-coverage <file>` | Save coverage file into `Test_Benchmark_Results/CoverageReport/`. |
|
||||
| _(no args)_ | Interactive `BenchmarkSwitcher` — pick from all `[Benchmark]` classes in the assembly. |
|
||||
|
||||
## Key files
|
||||
|
||||
### Serializer benchmark stack (the refactor scope)
|
||||
- [`AcBinaryVsMemPackBenchmark.cs`](AcBinaryVsMemPackBenchmark.cs) — BDN `[MemoryDiagnoser]` class. `[ParamsSource]`(TestData = Small/Medium/Large/Repeated/Deep) × `[Params]`(Engine = AcBinary/MemoryPack). `[GlobalSetup]` hidrátálja a Workloads scenario-ját + round-trip-verify.
|
||||
- [`BdnSummaryAdapter.cs`](BdnSummaryAdapter.cs) — `Summary → List<BenchmarkResult>` translator (groups per `(TestData × Engine)`, ns → ms conversion, GcStats → allocated-bytes-per-op). Calls `BenchmarkReportWriter.PrintGroupedResults` + `SaveAll(ctx with SourceTag="Bdn", ...)`.
|
||||
- [`Program.cs`](Program.cs) — BDN entry. Sets global `WithArtifactsPath(.../Benchmark/BDN)`; `--serializers` switch wires `BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>` + adapter.
|
||||
- [`Workloads/Scenarios/`](Workloads/Scenarios/) — shared workload types (see folder README).
|
||||
- [`Reporting/`](Reporting/) — shared reporting types (see folder README).
|
||||
|
||||
### KEEP benchmarks (independent — not in the serializer-refactor scope)
|
||||
- [`JitDisassemblyBenchmark.cs`](JitDisassemblyBenchmark.cs) — JIT analysis: emits `.asm` files for serialize/deserialize hot paths.
|
||||
- [`TaskHelperBenchmarks.cs`](TaskHelperBenchmarks.cs) — Task/timing utilities (WaitToAsync, custom ThreadPool, UtcNow.Ticks vs TickCount64).
|
||||
- [`ValueTypePassingBenchmark.cs`](ValueTypePassingBenchmark.cs) — Copy-by-value vs `in` parameter for 16-byte types.
|
||||
- [`RefForeachBenchmark.cs`](RefForeachBenchmark.cs) — Collection iteration patterns (array vs list, foreach vs index, ref readonly).
|
||||
- [`SourceGeneratorBenchmarks.cs`](SourceGeneratorBenchmarks.cs) — Source-generated vs runtime reflection serializers (PureContractlessBenchmark, RepeatedStringBenchmark).
|
||||
- [`SignalRCommunicationBenchmarks.cs`](SignalRCommunicationBenchmarks.cs) — Full-stack SignalR perf (client → server → response → round-trip).
|
||||
- [`SignalRRoundTripBenchmarks.cs`](SignalRRoundTripBenchmarks.cs) — SignalR primitives/complex/collections benchmarks.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `BenchmarkDotNet` | Benchmarking framework |
|
||||
| `MessagePack` | Serialization comparison target |
|
||||
| `MongoDB.Bson` | BSON comparison target |
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
| `BenchmarkDotNet` | BDN harness |
|
||||
| `MemoryPack` | Comparison target (used by Workloads scenarios + BDN class) |
|
||||
| `MessagePack` | Comparison target (KEEP benchmarks + Workloads MessagePackBenchmark scenario) |
|
||||
| `MongoDB.Bson` | KEEP-side comparison target |
|
||||
| `Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers` | VS Profiler integration |
|
||||
| `AyCode.Core` (ProjectReference) | AcBinary serializer |
|
||||
| `AyCode.Core.Tests` (ProjectReference) | Test data factory (`TestDataFactory`, `TestOrder_All_False/True`, `BenchmarkTestDataProvider*`) |
|
||||
| `AyCode.Core.Serializers.SourceGenerator` (Analyzer-only) | SGen for `[AcBinarySerializable]`-tagged types |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,741 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Output formatters for the benchmark suite: per-row console table, .log file, .LLM markdown file, .output
|
||||
/// binary hex dump. Consumes <see cref="BenchmarkResult"/> rows produced by the benchmark execution loop
|
||||
/// (Console-side <c>BenchmarkLoop</c> or BDN-side <c>BdnSummaryAdapter</c>) and emits human-readable +
|
||||
/// LLM-friendly outputs.
|
||||
///
|
||||
/// <para>The <see cref="ReportingContext"/> parameter encapsulates per-run state — <see cref="ReportingContext.SourceTag"/>
|
||||
/// drives the filename prefix ("Console" / "Bdn"), <see cref="ReportingContext.ResultsDirectory"/> is the
|
||||
/// resolved (walk-up-to-.sln) output folder, and the remaining fields (charset, iter counts, target sample
|
||||
/// window, CV threshold) carry the run-header info embedded in every emitted artifact.</para>
|
||||
/// </summary>
|
||||
public static class BenchmarkReportWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-cell-paired aggregation of an overall comparison. Captures three different aggregation
|
||||
/// strategies so the reader can judge whether the headline delta is dominated by one large cell
|
||||
/// (arithmetic mean) or representative of typical workload (geometric mean / median).
|
||||
/// </summary>
|
||||
/// <param name="ArithMeanPct">Arithmetic mean of µs/op — magnitude-weighted; biased toward Large cell.</param>
|
||||
/// <param name="GeoMeanPct">Geometric mean of per-cell ratios — magnitude-neutral; each cell weighted equally.</param>
|
||||
/// <param name="MedianPct">Median of per-cell ratios — outlier-resistant.</param>
|
||||
/// <param name="AcAvg">Arithmetic mean AcBinary value (µs/op or bytes).</param>
|
||||
/// <param name="MpAvg">Arithmetic mean MemPack value.</param>
|
||||
/// <param name="CellCount">Number of paired cells contributing to the geo/median.</param>
|
||||
public record OverallStats(double ArithMeanPct, double GeoMeanPct, double MedianPct, double AcAvg, double MpAvg, int CellCount);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static double ToPerOpMicros(double totalMs, int iterations) => iterations > 0 ? totalMs / iterations * 1000.0 : 0;
|
||||
|
||||
// Per-row per-op µs accessors — pull batch-time + iter from BenchmarkResult and convert. Used wherever
|
||||
// averaging or comparison happens across rows with potentially different iter counts (Winners summary,
|
||||
// Overall comparison, per-cell summary row). Keeping these as methods rather than properties on
|
||||
// BenchmarkResult preserves the result-as-data-bag distinction.
|
||||
public static double SerPerOp(BenchmarkResult r) => ToPerOpMicros(r.SerializeTimeMs, r.SerializeIterations);
|
||||
public static double DesPerOp(BenchmarkResult r) => ToPerOpMicros(r.DeserializeTimeMs, r.DeserializeIterations);
|
||||
public static double RtPerOp(BenchmarkResult r) => ToPerOpMicros(r.RoundTripTimeMs, r.RoundTripIterations);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a byte count to KB (1 KB = 1024 B). Display-only helper so allocation columns can
|
||||
/// render compact F2 KB values (e.g. <c>4.05 KB</c> instead of <c>4,144 B</c>) — header carries
|
||||
/// the unit so per-row entries stay numbers-only. CSV / raw-data outputs keep the precise byte
|
||||
/// integers untouched.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static double ToKilobytes(long bytes) => bytes / 1024.0;
|
||||
|
||||
/// <summary>
|
||||
/// Computes arithmetic + geometric + median aggregation of an AcBinary-vs-MemPack comparison
|
||||
/// across paired cells (joined by <c>TestDataName</c>). Per-cell pairing is required for the
|
||||
/// geo/median variants — a cell where AcBinary or MemPack is missing is dropped from all stats.
|
||||
/// Returns null when no paired cell has a valid value.
|
||||
/// </summary>
|
||||
public static OverallStats? ComputeOverallStats(List<BenchmarkResult> acResults, List<BenchmarkResult> mpResults, Func<BenchmarkResult, double> getValue)
|
||||
{
|
||||
if (acResults.Count == 0 || mpResults.Count == 0) return null;
|
||||
|
||||
var pairs = (from ac in acResults
|
||||
join mp in mpResults on ac.TestDataName equals mp.TestDataName
|
||||
let acV = getValue(ac)
|
||||
let mpV = getValue(mp)
|
||||
where acV > 0 && mpV > 0
|
||||
select (ac: acV, mp: mpV)).ToList();
|
||||
|
||||
if (pairs.Count == 0) return null;
|
||||
|
||||
var acAvg = pairs.Average(p => p.ac);
|
||||
var mpAvg = pairs.Average(p => p.mp);
|
||||
var ratios = pairs.Select(p => p.ac / p.mp).ToList();
|
||||
|
||||
// Geometric mean: exp(avg(ln(ratios))) — numerically stable vs Π ratios then ^(1/N).
|
||||
var geoMean = Math.Exp(ratios.Sum(Math.Log) / ratios.Count);
|
||||
|
||||
// Median (paired-ratio): for even N use the midpoint of the two middle values.
|
||||
var sorted = ratios.OrderBy(r => r).ToList();
|
||||
var median = sorted.Count % 2 == 1
|
||||
? sorted[sorted.Count / 2]
|
||||
: (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0;
|
||||
|
||||
return new OverallStats(
|
||||
ArithMeanPct: (acAvg / mpAvg - 1) * 100,
|
||||
GeoMeanPct: (geoMean - 1) * 100,
|
||||
MedianPct: (median - 1) * 100,
|
||||
AcAvg: acAvg,
|
||||
MpAvg: mpAvg,
|
||||
CellCount: ratios.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a per-op micros value with its inter-sample range and CV-threshold marker as
|
||||
/// <c>"26.86 (24.5..29.1)"</c> or <c>"26.86 (24.5..29.1) ⚠️5.2%"</c>. Median first, range in parentheses,
|
||||
/// CV warning suffix only when CV > <paramref name="unstableCvThreshold"/>. When min == max == median
|
||||
/// (single-sample / Debug / quick mode), collapses to bare median to avoid visual clutter.
|
||||
/// All time inputs are total-batch milliseconds; <paramref name="iterations"/> is the per-row iter
|
||||
/// count (post-adaptive-calibration).
|
||||
/// </summary>
|
||||
public static string FormatMicrosWithRange(double medianMs, double minMs, double maxMs, double stdDevMs, int iterations, CultureInfo inv, double unstableCvThreshold, double microOptCvThreshold = 0.0)
|
||||
{
|
||||
var med = ToPerOpMicros(medianMs, iterations);
|
||||
// No range data (single-sample fast path) — surface as bare median, identical to the prior format.
|
||||
if (minMs <= 0 && maxMs <= 0) return med.ToString("F2", inv);
|
||||
if (minMs >= medianMs && maxMs <= medianMs) return med.ToString("F2", inv);
|
||||
|
||||
var min = ToPerOpMicros(minMs, iterations);
|
||||
var max = ToPerOpMicros(maxMs, iterations);
|
||||
var range = $"{med.ToString("F2", inv)} ({min.ToString("F2", inv)}..{max.ToString("F2", inv)})";
|
||||
|
||||
// CV (coefficient of variation = stddev / mean) — two-band flagging:
|
||||
// ⚠️ X.X% : above the unstable threshold (e.g. 3%) — sub-threshold inter-engine
|
||||
// deltas on this row are essentially noise; entirely dismissable.
|
||||
// ⚠️micro X.X% : above the micro-opt threshold (e.g. 1.5%) but below unstable — not
|
||||
// noise but sub-2% deltas are at the edge of reliability; cross-check
|
||||
// with re-run or BDN before declaring a regression / improvement.
|
||||
// microOptCvThreshold = 0 disables the soft-flag band (backward-compat for callers that
|
||||
// only want the original unstable-only behaviour).
|
||||
if (medianMs > 0 && stdDevMs > 0)
|
||||
{
|
||||
var cv = stdDevMs / medianMs;
|
||||
var cvPct = (cv * 100).ToString("F1", inv);
|
||||
if (cv > unstableCvThreshold) return $"{range} ⚠️{cvPct}%";
|
||||
if (microOptCvThreshold > 0 && cv > microOptCvThreshold) return $"{range} ⚠️micro {cvPct}%";
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a signed percent delta with explicit sign for positive values (`+1.5%`, `-3.0%`, `0.0%`).
|
||||
/// Padded to 7 chars (e.g. ` +12.3%`, `-100.0%`) for column alignment in the Overall block.
|
||||
/// </summary>
|
||||
public static string FormatPctSigned(double pct) => pct.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture).PadLeft(6) + "%";
|
||||
|
||||
/// <summary>
|
||||
/// Renders one Overall row with arith / geo / median deltas + AcBinary/MemPack absolute means.
|
||||
/// Color is driven by the geometric-mean delta (magnitude-neutral signal). Skips silently when
|
||||
/// stats is null (no paired data).
|
||||
/// </summary>
|
||||
public static void WriteOverallLine(string label, string unit, OverallStats? stats, string fmt = "F2")
|
||||
{
|
||||
if (stats == null) return;
|
||||
|
||||
// Color follows geo-mean (the magnitude-neutral signal). The arith-mean column may show a
|
||||
// different sign when a single big cell dominates — that's exactly the signal we want to surface.
|
||||
System.Console.ForegroundColor = stats.GeoMeanPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} │ geo {FormatPctSigned(stats.GeoMeanPct)} │ median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="WriteOverallLine"/> but appends to a <see cref="StringBuilder"/> (no color).
|
||||
/// Used by the .log and .LLM file writers.
|
||||
/// </summary>
|
||||
public static void AppendOverallLine(StringBuilder sb, string label, string unit, OverallStats? stats, string fmt = "F2")
|
||||
{
|
||||
if (stats == null) return;
|
||||
sb.AppendLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} | geo {FormatPctSigned(stats.GeoMeanPct)} | median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
|
||||
}
|
||||
|
||||
public static void PrintResult(BenchmarkResult result)
|
||||
{
|
||||
// Numbers-only per-row entries; the column-headers carry units (µs/op, KB/op).
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result),7:F2}" : " N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result),7:F2}" : " N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp),7:F2}" : " N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp),7:F2}" : " N/A";
|
||||
System.Console.WriteLine($" {result.SerializerName,-40} | Size: {result.SerializedSize,8:N0} B | Ser: {ser} µs/op ({serAlloc} KB/op) | Des: {des} µs/op ({desAlloc} KB/op)");
|
||||
}
|
||||
|
||||
public static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
System.Console.WriteLine("\n");
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
// Print serializer options. [OrderType] suffix shows which TestOrder variant each preset serialised.
|
||||
var optionsMap = results
|
||||
.Where(r => r.OptionsDescription != null)
|
||||
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (optionsMap.Count > 0)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" Serializer Options:");
|
||||
foreach (var (name, orderType, opts) in optionsMap)
|
||||
System.Console.WriteLine($" {name} [{orderType}]: {opts}");
|
||||
}
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
// Order by Engine (so the same engine column-position stays stable across cells, especially
|
||||
// when two engines are within noise floor on a given cell — flip-flopping speed-rank produces
|
||||
// diff-hostile output across runs). RtPerOp is the secondary tiebreaker for cells where
|
||||
// multiple variants of the same engine exist (e.g. AcBinary SGen vs Runtime).
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.Engine).ThenBy(r => RtPerOp(r)).ToList();
|
||||
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
|
||||
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray));
|
||||
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
|
||||
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen));
|
||||
|
||||
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(172, '─') + "┐");
|
||||
// Header-only units; per-row entries are numbers (µs/op for time, KB/op for alloc, KB pair "ser / des" for Setup, B for Size).
|
||||
System.Console.WriteLine($"│ {"#",-4} │ {"Engine",-11} │ {"Options",-22} │ {"IO",-12} │ {"Mode",-8} │ {"Setup S/D KB",-14} │ {"Size B",-8} │ {"Ser µs/op",-10} │ {"SerAlc KB",-10} │ {"Des µs/op",-10} │ {"DesAlc KB",-10} │ {"RT µs/op",-10} │ {"RTAlc KB",-10} │");
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(13, '─')}┼{"─".PadRight(24, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(16, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┤");
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result):F2}" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result):F2}" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{RtPerOp(result):F2}" : "N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A";
|
||||
var rtAlloc = result.RoundTripAllocBytesPerOp > 0 ? $"{ToKilobytes(result.RoundTripAllocBytesPerOp):F2}" : "N/A";
|
||||
|
||||
// Highlight MemoryPack baseline (any Byte[]) and AcBinary headline contender (Byte[] + SGen) with win/lose colors.
|
||||
// The AcBinary Byte[]+Runtime variant is shown unhighlighted — it's contextual (SGen speed-up reference), not the headline.
|
||||
var isHighlighted = (result.Engine == BenchmarkEngine.MemoryPack && result.IoMode == BenchmarkIoMode.ByteArray)
|
||||
|| (result.Engine == BenchmarkEngine.AcBinary && result.IoMode == BenchmarkIoMode.ByteArray && result.DispatchMode == BenchmarkDispatchMode.SGen);
|
||||
|
||||
var prefix = isHighlighted ? "│►" : "│ ";
|
||||
var suffix = isHighlighted ? "◄│" : " │";
|
||||
|
||||
// Color logic: Green = winner (faster), Red = loser (slower)
|
||||
if (isHighlighted && memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var isMemPack = (result.Engine == BenchmarkEngine.MemoryPack && result.IoMode == BenchmarkIoMode.ByteArray);
|
||||
var memPackFaster = RtPerOp(memPackResult) < RtPerOp(acBinaryResult);
|
||||
|
||||
if (isMemPack)
|
||||
{
|
||||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
}
|
||||
}
|
||||
|
||||
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.Engine.ToDisplay(),-11} │ {result.OptionsPreset,-22} │ {result.IoMode.ToDisplay(),-12} │ {result.DispatchMode.ToDisplay(),-8} │ {setup,14} │ {size,8} │ {ser,10} │ {serAlloc,10} │ {des,10} │ {desAlloc,10} │ {rt,10} │ {rtAlloc,10}{suffix}");
|
||||
|
||||
if (isHighlighted)
|
||||
{
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Footer row: AcBinary (Byte[]) vs MemoryPack (Byte[]) comparison per column
|
||||
if (memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||||
// Per-op µs ratio (iter-independent) — Ser/Des may have different iter counts on the two rows.
|
||||
var serPct = SerPerOp(memPackResult) > 0 ? (SerPerOp(acBinaryResult) / SerPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var desPct = DesPerOp(memPackResult) > 0 ? (DesPerOp(acBinaryResult) / DesPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var rtPct = RtPerOp(memPackResult) > 0 ? (RtPerOp(acBinaryResult) / RtPerOp(memPackResult) - 1) * 100 : 0;
|
||||
|
||||
var serAllocPct = memPackResult.SerializeAllocBytesPerOp > 0 ? (acBinaryResult.SerializeAllocBytesPerOp / (double)memPackResult.SerializeAllocBytesPerOp - 1) * 100 : 0;
|
||||
var desAllocPct = memPackResult.DeserializeAllocBytesPerOp > 0 ? (acBinaryResult.DeserializeAllocBytesPerOp / (double)memPackResult.DeserializeAllocBytesPerOp - 1) * 100 : 0;
|
||||
var rtAllocPct = memPackResult.RoundTripAllocBytesPerOp > 0 ? (acBinaryResult.RoundTripAllocBytesPerOp / (double)memPackResult.RoundTripAllocBytesPerOp - 1) * 100 : 0;
|
||||
|
||||
// Footer separator: merge first 5 cols (#, Engine, Options, IO, Mode) → comparison label;
|
||||
// remaining 8 cols stay aligned (Setup S/D KB, Size, Ser µs/op, SerAlc KB, Des µs/op, DesAlc KB, RT µs/op, RTAlc KB).
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(13, '─')}┴{"─".PadRight(24, '─')}┴{"─".PadRight(14, '─')}┴{"─".PadRight(10, '─')}┼{"─".PadRight(16, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┤");
|
||||
// Merged label cell width = 4 + 11 + 22 + 12 + 8 + 4*3 (dropped separators) = 69
|
||||
System.Console.Write($"│ {"► AcBinary (Byte[]) vs MemoryPack (Byte[])",-69} │ ");
|
||||
|
||||
// Setup S/D KB (n/a for Byte[] vs Byte[] — neither pre-allocates)
|
||||
System.Console.Write($"{"—",14}");
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Size
|
||||
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{sizePct,+7:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Serialize
|
||||
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{serPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Serialize Alloc
|
||||
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{serAllocPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Deserialize
|
||||
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{desPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Deserialize Alloc
|
||||
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{desAllocPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Round-trip
|
||||
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{rtPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Round-trip Alloc
|
||||
System.Console.ForegroundColor = rtAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{rtAllocPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine(" │");
|
||||
}
|
||||
|
||||
// Closing line: merged on left (─ between cols 1-5), ┴ on the right (cols 6-13 boundary, 8 unmerged cells).
|
||||
System.Console.WriteLine($"└{"─".PadRight(6, '─')}─{"─".PadRight(13, '─')}─{"─".PadRight(24, '─')}─{"─".PadRight(14, '─')}─{"─".PadRight(10, '─')}┴{"─".PadRight(16, '─')}┴{"─".PadRight(10, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┘");
|
||||
}
|
||||
|
||||
// Summary: Best serializer for each category
|
||||
System.Console.WriteLine("\n");
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-40} │ {"Avg Value",-18}");
|
||||
System.Console.WriteLine($"{"─".PadRight(20, '─')}─┼─{"─".PadRight(40, '─')}─┼─{"─".PadRight(18, '─')}");
|
||||
|
||||
// Fastest Serialize — round-trip-only serializers (NamedPipe etc.) excluded:
|
||||
// their Serialize() captures the full round-trip and isn't comparable to a pure Ser metric.
|
||||
var fastestSer = results.Where(r => r.SerializeTimeMs > 0 && !r.IsRoundTripOnly)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => SerPerOp(r)) })
|
||||
.OrderBy(x => x.AvgPerOp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fastestSer != null)
|
||||
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-40} │ {fastestSer.AvgPerOp,12:F2} µs/op");
|
||||
|
||||
// Fastest Deserialize — round-trip-only serializers excluded (their Deserialize() is a no-op).
|
||||
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0 && !r.IsRoundTripOnly)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => DesPerOp(r)) })
|
||||
.OrderBy(x => x.AvgPerOp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fastestDes != null)
|
||||
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-40} │ {fastestDes.AvgPerOp,12:F2} µs/op");
|
||||
|
||||
// Smallest Size
|
||||
var smallestSize = results
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
|
||||
.OrderBy(x => x.AvgSize)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (smallestSize != null)
|
||||
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-40} │ {smallestSize.AvgSize,15:F0} B");
|
||||
|
||||
// Fastest Round-trip — iter-independent per-op average.
|
||||
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => RtPerOp(r)) })
|
||||
.OrderBy(x => x.AvgPerOp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fastestRt != null)
|
||||
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-40} │ {fastestRt.AvgPerOp,12:F2} µs/op");
|
||||
|
||||
// Overall AcBinary (SGen) vs MemoryPack comparison.
|
||||
var memPackSerResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
// Skip comparison if no data available
|
||||
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("── AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ──");
|
||||
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
|
||||
return;
|
||||
}
|
||||
|
||||
// All averages are over per-op µs (iter-independent). Three aggregations per metric.
|
||||
var sizeAcResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen)).ToList();
|
||||
var sizeMpResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray)).ToList();
|
||||
|
||||
var serStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, SerPerOp);
|
||||
var desStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, DesPerOp);
|
||||
var rtStats = ComputeOverallStats(acBinaryRtResults, memPackRtResults, RtPerOp);
|
||||
var sizeStats = ComputeOverallStats(sizeAcResults, sizeMpResults, r => r.SerializedSize);
|
||||
var serAllocStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, r => r.SerializeAllocBytesPerOp);
|
||||
var desAllocStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, r => r.DeserializeAllocBytesPerOp);
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("── AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ──");
|
||||
|
||||
WriteOverallLine("Serialize", "µs/op", serStats);
|
||||
WriteOverallLine("Deserialize", "µs/op", desStats);
|
||||
WriteOverallLine("Round-trip", "µs/op", rtStats);
|
||||
WriteOverallLine("Size", "B", sizeStats, "F0");
|
||||
WriteOverallLine("Ser Alloc", "B/op", serAllocStats, "F0");
|
||||
WriteOverallLine("Des Alloc", "B/op", desAllocStats, "F0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the unified file triplet — <c>{SourceTag}.FullBenchmark_{Build}_{timestamp}.{log, LLM, output}</c>
|
||||
/// — to <see cref="ReportingContext.ResultsDirectory"/>. The <c>.log</c> is the human-readable formatted
|
||||
/// view, the <c>.LLM</c> is the markdown LLM-paste-friendly view, and the <c>.output</c> is a binary hex
|
||||
/// dump of the Large test data's AcBinary-Default serialization (for raw inspection / wire-debugging).
|
||||
/// </summary>
|
||||
public static void SaveAll(ReportingContext ctx, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
Directory.CreateDirectory(ctx.ResultsDirectory);
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||
var baseFileName = $"{ctx.SourceTag}.FullBenchmark_{ctx.BuildConfiguration}_{timestamp}";
|
||||
var logFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.log");
|
||||
var outputFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.output");
|
||||
var llmFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.LLM");
|
||||
|
||||
// Save binary output to separate .output file.
|
||||
// Cast to TestDataSet<TestOrder_All_False> because Phase 1 hardcodes the benchmark variant.
|
||||
// Phase 2 will replace the cast with an options-driven dispatch (matching CreateSerializers).
|
||||
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large")) as TestDataSet<TestOrder_All_False>;
|
||||
if (largeTestData != null)
|
||||
{
|
||||
var outputSb = new StringBuilder();
|
||||
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
|
||||
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||||
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
outputSb.AppendLine();
|
||||
|
||||
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
|
||||
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
|
||||
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
|
||||
outputSb.AppendLine();
|
||||
outputSb.AppendLine("Hex dump:");
|
||||
outputSb.AppendLine(FormatHexDump(serializedBytes));
|
||||
|
||||
File.WriteAllText(outputFilePath, outputSb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
|
||||
}
|
||||
|
||||
// Save benchmark results to .log file
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
|
||||
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Source: {ctx.SourceTag}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Build: {ctx.BuildConfiguration}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Charset: {ctx.CharsetName}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ .NET: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription} ({Environment.Version})".PadRight(100) + "║");
|
||||
// For BDN-sourced contexts, warmup / samples / target are managed inside BDN's job config (not by
|
||||
// our adaptive engine) — surfacing the placeholder zeros as concrete numbers would be misleading.
|
||||
// Print "BDN-managed" instead; raw BDN config is recoverable from the BDN-native artifacts under .../BDN/.
|
||||
var isBdn = ctx.SourceTag == "Bdn";
|
||||
var iterationsHeader = isBdn ? "Iterations: BDN-managed" : $"Iterations: per-cell adaptive (~{ctx.TargetSampleMs} ms target)";
|
||||
var samplesHeader = isBdn ? "Samples: BDN-managed" : $"Samples: {ctx.BenchmarkSamples} (median) + 1 pilot discarded";
|
||||
sb.AppendLine($"║ {iterationsHeader}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ {samplesHeader}".PadRight(100) + "║");
|
||||
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
sb.AppendLine();
|
||||
|
||||
// Serializer options summary. The bracketed [OrderType] suffix shows which TestOrder variant
|
||||
// graph each benchmark serialised — AcBinary picks variant per options preset
|
||||
// (FastMode → _All_False, Default → _All_True; see BenchmarkLoop.UsesAllFalseVariant),
|
||||
// MemPack / MsgPack always use _All_False. Distinct() de-dupes across cells (each preset
|
||||
// appears once even though it runs on every test data set).
|
||||
var optionsMap = results
|
||||
.Where(r => r.OptionsDescription != null)
|
||||
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (optionsMap.Count > 0)
|
||||
{
|
||||
sb.AppendLine("=== SERIALIZER OPTIONS ===");
|
||||
foreach (var (name, orderType, opts) in optionsMap)
|
||||
sb.AppendLine($" {name} [{orderType}]: {opts}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// CSV-like data for easy import — keeps raw byte integers (no KB rounding) so external tools can compute precisely.
|
||||
// InvariantCulture is mandatory here: the decimal-separator dimension MUST be `.` so the comma-separated
|
||||
// field delimiters don't collide with locale-specific decimal commas (e.g. Hungarian "7,38" would split
|
||||
// a single F2 value across two CSV fields).
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
sb.AppendLine("=== RAW DATA (CSV) ===");
|
||||
sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMicrosPerOp,DeserializeMicrosPerOp,RoundTripMicrosPerOp,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,RoundTripAllocBytesPerOp,SetupSerializeAllocBytes,SetupDeserializeAllocBytes");
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
sb.AppendLine($"{result.TestDataName},{result.Engine.ToDisplay()},{result.IoMode.ToDisplay()},{result.DispatchMode.ToDisplay()},{result.OptionsPreset},{result.SerializedSize},{SerPerOp(result).ToString("F2", inv)},{DesPerOp(result).ToString("F2", inv)},{RtPerOp(result).ToString("F2", inv)},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.RoundTripAllocBytesPerOp},{result.SetupSerializeAllocBytes},{result.SetupDeserializeAllocBytes}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Formatted results
|
||||
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
|
||||
sb.AppendLine("(►) = Highlighted: MemoryPack (Byte[]) (baseline) and AcBinary (Byte[])");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
// Order by Engine (stable column-position across cells, see PrintGroupedResults for rationale);
|
||||
// RtPerOp is the secondary tiebreaker between same-engine variants (SGen vs Runtime).
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.Engine).ThenBy(r => RtPerOp(r)).ToList();
|
||||
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray));
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen));
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"--- {testData.DisplayName} ---");
|
||||
sb.AppendLine($"{"#",-4} {"Serializer",-42} {"Size B",-12} {"Setup S/D KB",-14} {"Ser µs/op",-12} {"Des µs/op",-12} {"RT µs/op",-12} {"SerAlc KB",-11} {"DesAlc KB",-11}");
|
||||
sb.AppendLine(new string('-', 140));
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var isHighlighted = ((result.Engine == BenchmarkEngine.MemoryPack || result.Engine == BenchmarkEngine.AcBinary) && result.IoMode == BenchmarkIoMode.ByteArray);
|
||||
var prefix = isHighlighted ? "► " : " ";
|
||||
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result):F2}" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result):F2}" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{RtPerOp(result):F2}" : "N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A";
|
||||
|
||||
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-40} {size,-12} {setup,-14} {ser,-12} {des,-12} {rt,-12} {serAlloc,-11} {desAlloc,-11}");
|
||||
}
|
||||
|
||||
// Summary row for this test data (vs MemoryPack)
|
||||
if (memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = SerPerOp(memPackResult) > 0 ? (SerPerOp(acBinaryResult) / SerPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var desPct = DesPerOp(memPackResult) > 0 ? (DesPerOp(acBinaryResult) / DesPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var rtPct = RtPerOp(memPackResult) > 0 ? (RtPerOp(acBinaryResult) / RtPerOp(memPackResult) - 1) * 100 : 0;
|
||||
|
||||
sb.AppendLine($" AcBinary (Byte[]) vs MemoryPack (Byte[]): Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
|
||||
}
|
||||
}
|
||||
|
||||
// Summary comparison (vs MemoryPack)
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ===");
|
||||
|
||||
var memPackSerResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
// Skip comparison block if either side has no Byte[] data
|
||||
if (memPackRtResults2.Count == 0 || acBinaryRtResults2.Count == 0)
|
||||
{
|
||||
sb.AppendLine(" (Comparison requires both serialize and deserialize data)");
|
||||
File.WriteAllText(logFilePath, sb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||||
|
||||
SaveLlmResults(ctx, llmFilePath, results, testDataSets);
|
||||
return;
|
||||
}
|
||||
|
||||
var sizeAcResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen)).ToList();
|
||||
var sizeMpResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray)).ToList();
|
||||
|
||||
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, SerPerOp));
|
||||
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, r => r.SerializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, DesPerOp));
|
||||
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, r => r.DeserializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResults2, memPackRtResults2, RtPerOp));
|
||||
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(sizeAcResults2, sizeMpResults2, r => r.SerializedSize), "F0");
|
||||
|
||||
File.WriteAllText(logFilePath, sb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||||
|
||||
// Save LLM-optimized results
|
||||
SaveLlmResults(ctx, llmFilePath, results, testDataSets);
|
||||
}
|
||||
|
||||
private static void SaveLlmResults(ReportingContext ctx, string filePath, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# AcBinary Benchmark [{ctx.SourceTag}] {ctx.BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||
// BDN-sourced: warmup / iter / samples are BDN-job-config-managed (see .../BDN/ artifacts for raw N).
|
||||
// Console-sourced: our adaptive engine emits real numbers.
|
||||
var runStatsHeader = ctx.SourceTag == "Bdn"
|
||||
? "Iterations: BDN-managed | Warmup: BDN-managed | Samples: BDN-managed"
|
||||
: $"Iterations: per-cell adaptive (target ~{ctx.TargetSampleMs} ms/sample) | Warmup: {ctx.WarmupIterations} per phase (Ser/Des isolated) | Samples: {ctx.BenchmarkSamples} (median) + 1 pilot discarded";
|
||||
// F1 formatter without an explicit IFormatProvider would pick the current culture (e.g. "3,0%"
|
||||
// in Hungarian locale) — break parsability of the .LLM. Force InvariantCulture so the header
|
||||
// matches the row values which already go through CultureInfo.InvariantCulture in FormatMicrosWithRange.
|
||||
var unstablePct = (ctx.UnstableCVThreshold * 100).ToString("F1", CultureInfo.InvariantCulture);
|
||||
var microPct = (ctx.MicroOptCVThreshold * 100).ToString("F1", CultureInfo.InvariantCulture);
|
||||
sb.AppendLine($"Charset: {ctx.CharsetName} | {runStatsHeader} | .NET: {Environment.Version} | UnstableCV: {unstablePct}% | MicroOptCV: {microPct}%");
|
||||
sb.AppendLine("Baseline: MemoryPack (Byte[]) (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
|
||||
|
||||
// Options summary. Bracketed [OrderType] surfaces the TestOrder variant each preset serialised —
|
||||
// see SaveAll for the variant-dispatch rationale.
|
||||
var optionsMap = results
|
||||
.Where(r => r.OptionsDescription != null)
|
||||
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (optionsMap.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Options");
|
||||
sb.AppendLine();
|
||||
foreach (var (name, orderType, opts) in optionsMap)
|
||||
sb.AppendLine($"- **{name} [{orderType}]**: {opts}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Results");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("TestData | Engine | IO | Mode | Options | Size(B) | Ser(µs/op) | Deser(µs/op) | RT(µs/op) | SerAlloc(KB/op) | DesAlloc(KB/op) | RTAlloc(KB/op) | Setup S/D(KB) | Iter Ser/Des");
|
||||
sb.AppendLine("---|---|---|---|---|---|---|---|---|---|---|---|---|---");
|
||||
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
// Order by Engine for stable column-position across cells (see PrintGroupedResults for rationale).
|
||||
var testResults = results
|
||||
.Where(r => r.TestDataName == testData.DisplayName)
|
||||
.OrderBy(r => r.Engine).ThenBy(RtPerOp)
|
||||
.ToList();
|
||||
|
||||
foreach (var r in testResults)
|
||||
{
|
||||
var ser = r.SerializeTimeMs > 0 ? FormatMicrosWithRange(r.SerializeTimeMs, r.SerializeTimeMinMs, r.SerializeTimeMaxMs, r.SerializeTimeStdDevMs, r.SerializeIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold) : "-";
|
||||
var des = r.DeserializeTimeMs > 0 ? FormatMicrosWithRange(r.DeserializeTimeMs, r.DeserializeTimeMinMs, r.DeserializeTimeMaxMs, r.DeserializeTimeStdDevMs, r.DeserializeIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold) : "-";
|
||||
var rt = r.RoundTripTimeMs > 0
|
||||
? (r.IsRoundTripOnly
|
||||
? FormatMicrosWithRange(r.RoundTripTimeMs, r.RoundTripTimeMinMs, r.RoundTripTimeMaxMs, r.RoundTripTimeStdDevMs, r.RoundTripIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold)
|
||||
: RtPerOp(r).ToString("F2", inv))
|
||||
: "-";
|
||||
|
||||
var serAlloc = r.SerializeTimeMs > 0 ? ToKilobytes(r.SerializeAllocBytesPerOp).ToString("F2", inv) : "-";
|
||||
var desAlloc = r.DeserializeTimeMs > 0 ? ToKilobytes(r.DeserializeAllocBytesPerOp).ToString("F2", inv) : "-";
|
||||
var rtAlloc = r.RoundTripAllocBytesPerOp > 0 ? ToKilobytes(r.RoundTripAllocBytesPerOp).ToString("F2", inv) : "-";
|
||||
var setupAlloc = $"{ToKilobytes(r.SetupSerializeAllocBytes).ToString("F2", inv)} / {ToKilobytes(r.SetupDeserializeAllocBytes).ToString("F2", inv)}";
|
||||
|
||||
var iterCol = r.IsRoundTripOnly
|
||||
? r.RoundTripIterations.ToString(inv)
|
||||
: $"{(r.SerializeIterations > 0 ? r.SerializeIterations.ToString(inv) : "-")} / {(r.DeserializeIterations > 0 ? r.DeserializeIterations.ToString(inv) : "-")}";
|
||||
sb.AppendLine($"{r.TestDataName} | {r.Engine.ToDisplay()} | {r.IoMode.ToDisplay()} | {r.DispatchMode.ToDisplay()} | {r.OptionsPreset} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc} | {rtAlloc} | {setupAlloc} | {iterCol}");
|
||||
}
|
||||
}
|
||||
|
||||
// Overall AcBinary (SGen, Byte[]) vs MemoryPack (Byte[]) comparison
|
||||
var memPackByteArrayResults = results.Where(r => r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray).ToList();
|
||||
var acBinarySGenByteArrayResults = results.Where(r => r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen).ToList();
|
||||
var memPackSerResultsLlm = memPackByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResultsLlm = memPackByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResultsLlm = memPackByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
|
||||
var acBinarySerResultsLlm = acBinarySGenByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResultsLlm = acBinarySGenByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResultsLlm = acBinarySGenByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
if (memPackRtResultsLlm.Count > 0 && acBinaryRtResultsLlm.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Overall: AcBinary (Byte[], SGen) vs MemoryPack (Byte[])");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Three aggregations of per-cell results: **arith** = arithmetic mean of µs/op (magnitude-weighted, Large cell dominates); **geo** = geometric mean of per-cell ratios (each cell weighted equally); **median** = median of per-cell ratios (outlier-resistant). Negative % = AcBinary faster/smaller; positive % = MemPack faster/smaller. The geo/median variants surface when a single big cell skews the arithmetic mean.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, SerPerOp));
|
||||
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, r => r.SerializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, DesPerOp));
|
||||
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, r => r.DeserializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResultsLlm, memPackRtResultsLlm, RtPerOp));
|
||||
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(acBinarySGenByteArrayResults, memPackByteArrayResults, r => r.SerializedSize), "F0");
|
||||
sb.AppendLine("```");
|
||||
}
|
||||
|
||||
File.WriteAllText(filePath, sb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
|
||||
/// </summary>
|
||||
public static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < bytes.Length; i += bytesPerLine)
|
||||
{
|
||||
// Offset
|
||||
sb.Append($"{i:X8} ");
|
||||
|
||||
// Hex bytes
|
||||
for (var j = 0; j < bytesPerLine; j++)
|
||||
{
|
||||
if (i + j < bytes.Length)
|
||||
sb.Append($"{bytes[i + j]:X2} ");
|
||||
else
|
||||
sb.Append(" ");
|
||||
|
||||
if (j == 7) sb.Append(' '); // Extra space in middle
|
||||
}
|
||||
|
||||
sb.Append(" |");
|
||||
|
||||
// ASCII representation
|
||||
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
|
||||
{
|
||||
var b = bytes[i + j];
|
||||
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
|
||||
}
|
||||
|
||||
sb.AppendLine("|");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Per-cell benchmark result row. Populated by the benchmark execution loop (Console-side
|
||||
/// <c>BenchmarkLoop.RunBenchmarksForTestData</c> / BDN-side <c>BdnSummaryAdapter</c>); consumed by the
|
||||
/// output formatters in <c>BenchmarkReportWriter</c> (console table + .log + .LLM file writers).
|
||||
/// Pure DTO — no behaviour.
|
||||
/// </summary>
|
||||
public sealed class BenchmarkResult
|
||||
{
|
||||
public string TestDataName { get; set; } = "";
|
||||
public BenchmarkEngine Engine { get; set; }
|
||||
public BenchmarkIoMode IoMode { get; set; }
|
||||
public BenchmarkDispatchMode DispatchMode { get; set; }
|
||||
public string OptionsPreset { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// CLR type name of the order graph serialised in this row (e.g. <c>"TestOrder_All_False"</c>,
|
||||
/// <c>"TestOrder_All_True"</c>). Captured from <see cref="ISerializerBenchmark.OrderTypeName"/> in
|
||||
/// the runner loop; surfaced in the SERIALIZER OPTIONS section of every output
|
||||
/// (.log, .LLM, console) so the reader can correlate each preset with its TestOrder variant
|
||||
/// without inflating the per-row tables with an extra column.
|
||||
/// </summary>
|
||||
public string OrderTypeName { get; set; } = "";
|
||||
|
||||
/// <summary>True if Serialize() captures a full round-trip and Deserialize() is a no-op
|
||||
/// (single-use streaming transports like NamedPipe). Excluded from "Fastest Serialize" / "Fastest Deserialize"
|
||||
/// winners rankings; still ranked in "Fastest Round-trip". Display-side: Ser µs/op / SerAlloc / Des µs/op / DesAlloc
|
||||
/// all show "N/A" since they were never measured separately; RT µs/op / RT Alloc carry the full round-trip values.</summary>
|
||||
public bool IsRoundTripOnly { get; set; }
|
||||
|
||||
/// <summary>Synthesized display name for backwards compatibility / single-string-row scenarios. Includes DispatchMode so SGen and Runtime variants of the same preset don't collide in grouping (e.g. SUMMARY: WINNERS).</summary>
|
||||
public string SerializerName => $"{Engine.ToDisplay()} ({IoMode.ToDisplay()}, {OptionsPreset}, {DispatchMode.ToDisplay()})";
|
||||
|
||||
public string? OptionsDescription { get; set; }
|
||||
public int SerializedSize { get; set; }
|
||||
public double SerializeTimeMs { get; set; }
|
||||
public double DeserializeTimeMs { get; set; }
|
||||
|
||||
// Per-sample min/max alongside the median (median is the *Time*Ms field above). Surfaces
|
||||
// inter-sample range — the visible noise floor for the row. 0 when the operation was skipped
|
||||
// (mode != "all"/"ser"/"des") or when a single-sample fast path was used (min == max == median).
|
||||
public double SerializeTimeMinMs { get; set; }
|
||||
public double SerializeTimeMaxMs { get; set; }
|
||||
public double DeserializeTimeMinMs { get; set; }
|
||||
public double DeserializeTimeMaxMs { get; set; }
|
||||
|
||||
// Sample-population stddev (ms). Used by FormatMicrosWithRange to compute CV (stddev/mean)
|
||||
// and emit the ⚠️ marker on rows above Configuration.UnstableCVThreshold. 0 in single-sample mode.
|
||||
public double SerializeTimeStdDevMs { get; set; }
|
||||
public double DeserializeTimeStdDevMs { get; set; }
|
||||
|
||||
// Per-row adaptive iteration count (post-CalibrateIterations). Each Ser and Des function calibrates
|
||||
// independently to land its sample window at ~Configuration.TargetSampleMs; per-op µs is then iter-independent
|
||||
// (`SerializeTimeMs / SerializeIterations * 1000`). For round-trip-only rows (NamedPipe etc.),
|
||||
// RoundTripIterations carries the calibrated iter count; SerializeIterations and DeserializeIterations
|
||||
// stay 0 (Ser and Des are not separately measurable on those rows).
|
||||
//
|
||||
// BDN-sourced rows (populated by <c>BdnSummaryAdapter</c>) follow a different convention: per-op time
|
||||
// is stored directly in <c>*TimeMs</c> with <c>Iterations = 1</c>, so the same <c>TimeMs / Iterations * 1000</c>
|
||||
// formula yields per-op µs. The actual BDN N count is NOT preserved on these rows — consumers that
|
||||
// read <c>SerializeIterations</c> as a nominal loop count (e.g. "bytes allocated over N iterations")
|
||||
// will misinterpret BDN rows. For the raw N, read the BDN-native artifacts under
|
||||
// <c>Test_Benchmark_Results/Benchmark/BDN/</c>.
|
||||
public int SerializeIterations { get; set; }
|
||||
public int DeserializeIterations { get; set; }
|
||||
public int RoundTripIterations { get; set; }
|
||||
|
||||
public long SerializeAllocBytesPerOp { get; set; }
|
||||
public long DeserializeAllocBytesPerOp { get; set; }
|
||||
public long SetupSerializeAllocBytes { get; set; }
|
||||
public long SetupDeserializeAllocBytes { get; set; }
|
||||
|
||||
/// <summary>Total round-trip time. For in-memory benchmarks: synthesized so that
|
||||
/// <c>RoundTripTimeMs / RoundTripIterations</c> yields the correct <c>SerPerOp + DesPerOp</c> µs/op
|
||||
/// (necessary because Ser and Des may have different iter counts post-calibration).
|
||||
/// For round-trip-only benchmarks (NamedPipe etc.): the directly-measured pipe round-trip time.</summary>
|
||||
public double RoundTripTimeMs { get; set; }
|
||||
|
||||
// Round-trip min/max + stddev — only populated for round-trip-only benchmarks (NamedPipe etc.) where
|
||||
// RT is directly measured. For in-memory rows RT = Ser + Des, which has no single-sample
|
||||
// distribution; surface Ser/Des range separately instead.
|
||||
public double RoundTripTimeMinMs { get; set; }
|
||||
public double RoundTripTimeMaxMs { get; set; }
|
||||
public double RoundTripTimeStdDevMs { get; set; }
|
||||
|
||||
/// <summary>Total round-trip allocation per op. For in-memory benchmarks: <c>SerializeAlloc + DeserializeAlloc</c>.
|
||||
/// For round-trip-only benchmarks: process-wide allocation measured via <see cref="GC.GetTotalAllocatedBytes"/>
|
||||
/// (covers ALL threads — client, server-drain, channel internals — not just the caller).</summary>
|
||||
public long RoundTripAllocBytesPerOp { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Reporting
|
||||
|
||||
Shared reporting types — the `BenchmarkResult` DTO that captures one cell of a benchmark run + the `BenchmarkReportWriter` that turns a list of these into the unified `.log` / `.LLM` / `.output` triplet + the `ReportingContext` bundle that parameterises both runners.
|
||||
|
||||
## Layout
|
||||
|
||||
- [`BenchmarkResult.cs`](BenchmarkResult.cs) — per-cell result row. `(TestData × Engine × IoMode × OptionsPreset × DispatchMode)` tuple + Ser / Des / RT timings (median, min, max, stddev — all ms-batch units), iter counts (post-calibration), allocated bytes per op, setup-side one-time alloc, `IsRoundTripOnly` flag, derived `SerializerName`. Pure DTO — no behaviour. Populated by either:
|
||||
- Console `BenchmarkLoop.RunBenchmarksForTestData` (after adaptive measurement)
|
||||
- BDN `BdnSummaryAdapter.Translate` (after BDN Summary is in hand)
|
||||
|
||||
- [`ReportingContext.cs`](ReportingContext.cs) — record bundle for the writer:
|
||||
- `SourceTag` — `"Console"` / `"Bdn"`; drives the filename prefix
|
||||
- `ResultsDirectory` — resolved at startup via `ResolveResultsDirectory()` walking up from `AppContext.BaseDirectory` to the nearest `AyCode.Core.sln`, then `Test_Benchmark_Results/Benchmark/`. Worktree-aware.
|
||||
- `BuildConfiguration` — `"Debug"` / `"Release"` / `"NativeAOT"`; rendered into both the filename AND the report header
|
||||
- `Utf8NoBom` — shared `UTF8Encoding(false)` for all `File.WriteAllText` calls
|
||||
- `CharsetName`, `WarmupIterations`, `BenchmarkSamples`, `TargetSampleMs`, `UnstableCVThreshold` — run-header info embedded in every emitted artifact
|
||||
|
||||
- [`BenchmarkReportWriter.cs`](BenchmarkReportWriter.cs) — the writer itself:
|
||||
- `SaveAll(ctx, results, testDataSets)` — orchestrator. Writes the `.log` (formatted text + CSV + per-cell tables + Overall aggregation), `.LLM` (markdown table + Overall aggregation), and `.output` (hex dump of the Large cell's AcBinary serialization). All three land in `ctx.ResultsDirectory` with the `{ctx.SourceTag}.FullBenchmark_{Build}_{ts}.<ext>` filename pattern.
|
||||
- `PrintGroupedResults(results, testDataSets)` — colored per-cell tables to `System.Console`. Highlights MemoryPack (baseline) and AcBinary (SGen-Byte[]) rows with green/red win/lose colors, footer row shows pct deltas per metric.
|
||||
- `PrintResult(result)` — single-line summary printed during the per-cell loop (real-time progress signal).
|
||||
- `ComputeOverallStats(acResults, mpResults, valueSelector)` — paired-cell aggregation across `TestDataName` (arithmetic mean / geometric mean / median of per-cell ratios). Null-safe.
|
||||
- `FormatMicrosWithRange(...)` — `26.86 (24.50..29.10)` style with ⚠️CV-warning suffix when stddev/median exceeds the `UnstableCVThreshold`. All formatting goes through `CultureInfo.InvariantCulture` so the CSV section in `.log` stays parseable regardless of the host locale.
|
||||
- `ToPerOpMicros` / `SerPerOp` / `DesPerOp` / `RtPerOp` / `ToKilobytes` / `FormatPctSigned` / `FormatHexDump` / `AppendOverallLine` — helper utilities used inline by the report-rendering methods.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Time units in `BenchmarkResult`**: all `*TimeMs` fields are total-batch milliseconds. Per-op µs = `TimeMs / Iterations * 1000`. For BDN-sourced rows the adapter stores `Mean_ns / 1e6` with `Iterations = 1`, so the same formula yields per-op µs directly (`ms * 1000 = µs`).
|
||||
- **InvariantCulture** is enforced everywhere a numeric value is rendered to file (`.log` CSV section, `.LLM` markdown cells). Console-output (the colored tables) uses default culture for human-friendliness.
|
||||
- **`SourceTag` discriminator**: appears in three places — the filename prefix (`Console.` / `Bdn.`), the `.log` header (`║ Source: Console`), the `.LLM` H1 (`# AcBinary Benchmark [Console] Release ...`). Anyone diffing or grepping outputs can pin the source unambiguously.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Context bundle for the unified benchmark report writer. Same record on both sides (Console / BDN),
|
||||
/// the <see cref="SourceTag"/> differs ("Console" / "Bdn") and drives the filename prefix
|
||||
/// (e.g. <c>Console.FullBenchmark_Release_{timestamp}.LLM</c> vs <c>Bdn.FullBenchmark_Release_{timestamp}.LLM</c>).
|
||||
/// The <see cref="ResultsDirectory"/> resolution walks up from <see cref="AppContext.BaseDirectory"/> to the
|
||||
/// nearest <c>AyCode.Core.sln</c> and combines with <c>Test_Benchmark_Results\Benchmark</c> — works across
|
||||
/// build modes (Debug / Release / AOT publish) and worktrees (each worktree has its own .sln, so its bench
|
||||
/// results land alongside its code). The remaining fields capture run-header information (charset, iter
|
||||
/// counts, target sample window, CV threshold) so the writer can render a self-documenting header in both
|
||||
/// the <c>.log</c> and <c>.LLM</c> outputs.
|
||||
/// </summary>
|
||||
public sealed record ReportingContext(
|
||||
string SourceTag,
|
||||
string ResultsDirectory,
|
||||
string BuildConfiguration,
|
||||
UTF8Encoding Utf8NoBom,
|
||||
string CharsetName,
|
||||
int WarmupIterations,
|
||||
int BenchmarkSamples,
|
||||
int TargetSampleMs,
|
||||
double UnstableCVThreshold,
|
||||
double MicroOptCVThreshold)
|
||||
{
|
||||
/// <summary>
|
||||
/// Walks up from the assembly's BaseDirectory to find the repo root (marker: <c>AyCode.Core.sln</c>).
|
||||
/// Returns <c>{repoRoot}\Test_Benchmark_Results\Benchmark</c>. Worktree-aware: if running from a
|
||||
/// worktree, the walk finds the worktree's own .sln (each worktree has its own checkout), so
|
||||
/// results land in the worktree's results folder — the natural place when the worktree's code
|
||||
/// changes are what produced the numbers.
|
||||
/// </summary>
|
||||
public static string ResolveResultsDirectory()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AyCode.Core.sln")))
|
||||
dir = dir.Parent;
|
||||
if (dir == null)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot locate repo root (AyCode.Core.sln) from AppContext.BaseDirectory: " + AppContext.BaseDirectory);
|
||||
return Path.Combine(dir.FullName, "Test_Benchmark_Results", "Benchmark");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,713 +0,0 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using BenchmarkDotNet.Jobs;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
using MongoDB.Bson.Serialization;
|
||||
using System.IO;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using AyCode.Core.Serializers;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal benchmark to test if BenchmarkDotNet works without stack overflow.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
public class MinimalBenchmark
|
||||
{
|
||||
private byte[] _data = null!;
|
||||
private string _json = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
// Use very simple data - no circular references
|
||||
var simpleData = new { Id = 1, Name = "Test", Value = 42.5 };
|
||||
_json = System.Text.Json.JsonSerializer.Serialize(simpleData);
|
||||
_data = Encoding.UTF8.GetBytes(_json);
|
||||
Console.WriteLine($"Setup complete. Data size: {_data.Length} bytes");
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public int GetLength() => _data.Length;
|
||||
|
||||
[Benchmark]
|
||||
public string GetJson() => _json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary vs JSON benchmark with simple flat objects (no circular references).
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
public class SimpleBinaryBenchmark
|
||||
{
|
||||
private PrimitiveTestClass _testData = null!;
|
||||
private byte[] _binaryData = null!;
|
||||
private string _jsonData = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_testData = TestDataFactory.CreatePrimitiveTestData();
|
||||
_binaryData = AcBinarySerializer.Serialize(_testData, AcBinarySerializerOptions.WithoutReferenceHandling);
|
||||
_jsonData = AcJsonSerializer.Serialize(_testData, AcJsonSerializerOptions.WithoutReferenceHandling);
|
||||
|
||||
Console.WriteLine($"Binary: {_binaryData.Length} bytes, JSON: {_jsonData.Length} chars");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Binary Serialize")]
|
||||
public byte[] SerializeBinary() => AcBinarySerializer.Serialize(_testData, AcBinarySerializerOptions.WithoutReferenceHandling);
|
||||
|
||||
[Benchmark(Description = "JSON Serialize", Baseline = true)]
|
||||
public string SerializeJson() => AcJsonSerializer.Serialize(_testData, AcJsonSerializerOptions.WithoutReferenceHandling);
|
||||
|
||||
[Benchmark(Description = "Binary Deserialize")]
|
||||
public PrimitiveTestClass? DeserializeBinary() => AcBinaryDeserializer.Deserialize<PrimitiveTestClass>(_binaryData);
|
||||
|
||||
[Benchmark(Description = "JSON Deserialize")]
|
||||
public PrimitiveTestClass? DeserializeJson() => AcJsonDeserializer.Deserialize<PrimitiveTestClass>(_jsonData, AcJsonSerializerOptions.WithoutReferenceHandling);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complex hierarchy benchmark - AcBinary vs JSON only (no MessagePack to isolate the issue).
|
||||
/// Uses AcBinary without reference handling.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class ComplexBinaryBenchmark
|
||||
{
|
||||
private TestOrder _testOrder = null!;
|
||||
private byte[] _acBinaryData = null!;
|
||||
private string _jsonData = null!;
|
||||
|
||||
private AcBinarySerializerOptions _binaryOptions = null!;
|
||||
private AcJsonSerializerOptions _jsonOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
Console.WriteLine("Creating test data...");
|
||||
_testOrder = TestDataFactory.CreateBenchmarkOrder(
|
||||
itemCount: 2,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 3);
|
||||
Console.WriteLine($"Created order with {_testOrder.Items.Count} items");
|
||||
|
||||
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
|
||||
|
||||
Console.WriteLine("Serializing AcBinary...");
|
||||
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
|
||||
Console.WriteLine($"AcBinary size: {_acBinaryData.Length} bytes");
|
||||
|
||||
Console.WriteLine("Serializing JSON...");
|
||||
_jsonData = AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
|
||||
Console.WriteLine($"JSON size: {_jsonData.Length} chars");
|
||||
|
||||
var jsonBytes = Encoding.UTF8.GetByteCount(_jsonData);
|
||||
Console.WriteLine($"\n=== SIZE COMPARISON ===");
|
||||
Console.WriteLine($"AcBinary: {_acBinaryData.Length,8:N0} bytes ({100.0 * _acBinaryData.Length / jsonBytes:F1}%)");
|
||||
Console.WriteLine($"JSON: {jsonBytes,8:N0} bytes (100.0%)");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize")]
|
||||
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
|
||||
|
||||
[Benchmark(Description = "JSON Serialize", Baseline = true)]
|
||||
public string Serialize_Json() => AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize")]
|
||||
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
|
||||
|
||||
[Benchmark(Description = "JSON Deserialize")]
|
||||
public TestOrder? Deserialize_Json() => AcJsonDeserializer.Deserialize<TestOrder>(_jsonData, _jsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full comparison with MessagePack and BSON - AcBinary uses NO reference handling everywhere.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class MessagePackComparisonBenchmark
|
||||
{
|
||||
private TestOrder _testOrder = null!;
|
||||
private byte[] _acBinaryData = null!;
|
||||
private byte[] _msgPackData = null!;
|
||||
private byte[] _bsonData = null!;
|
||||
private string _jsonData = null!;
|
||||
|
||||
private AcBinarySerializerOptions _binaryOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackOptions = null!;
|
||||
private AcJsonSerializerOptions _jsonOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
Console.WriteLine("Creating test data...");
|
||||
_testOrder = TestDataFactory.CreateBenchmarkOrder(
|
||||
itemCount: 2,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 3);
|
||||
|
||||
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
_jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
|
||||
|
||||
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
|
||||
_jsonData = AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
|
||||
|
||||
// MessagePack serialization in try-catch to see if it fails
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Serializing MessagePack...");
|
||||
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
|
||||
Console.WriteLine($"MessagePack size: {_msgPackData.Length} bytes");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"MessagePack serialization failed: {ex.Message}");
|
||||
_msgPackData = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// BSON serialization
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Serializing BSON...");
|
||||
var bsonDoc = _testOrder.ToBsonDocument();
|
||||
_bsonData = bsonDoc.ToBson();
|
||||
Console.WriteLine($"BSON size: {_bsonData.Length} bytes");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"BSON serialization failed: {ex.Message}");
|
||||
_bsonData = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var jsonBytes = Encoding.UTF8.GetByteCount(_jsonData);
|
||||
Console.WriteLine($"\n=== SIZE COMPARISON ===");
|
||||
Console.WriteLine($"AcBinary: {_acBinaryData.Length,8:N0} bytes ({100.0 * _acBinaryData.Length / jsonBytes:F1}%)");
|
||||
Console.WriteLine($"MessagePack: {_msgPackData.Length,8:N0} bytes ({100.0 * _msgPackData.Length / jsonBytes:F1}%)");
|
||||
Console.WriteLine($"BSON: {_bsonData.Length,8:N0} bytes ({100.0 * _bsonData.Length / jsonBytes:F1}%)");
|
||||
Console.WriteLine($"JSON: {jsonBytes,8:N0} bytes (100.0%)");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize")]
|
||||
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
|
||||
|
||||
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
|
||||
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "BSON Serialize")]
|
||||
public byte[] Serialize_Bson() => _testOrder.ToBsonDocument().ToBson();
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize")]
|
||||
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
|
||||
|
||||
[Benchmark(Description = "MessagePack Deserialize")]
|
||||
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "BSON Deserialize")]
|
||||
public TestOrder? Deserialize_Bson()
|
||||
{
|
||||
if (_bsonData == null || _bsonData.Length == 0) return null;
|
||||
using var ms = new MemoryStream(_bsonData);
|
||||
using var reader = new BsonBinaryReader(ms);
|
||||
return BsonSerializer.Deserialize<TestOrder>(reader);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive AcBinary vs MessagePack comparison benchmark.
|
||||
/// Tests: NoRef (everywhere), Populate, Serialize, Deserialize, Size
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class AcBinaryVsMessagePackFullBenchmark
|
||||
{
|
||||
// Test data
|
||||
private TestOrder _testOrder = null!;
|
||||
private TestOrder _populateTarget = null!;
|
||||
|
||||
// Serialized data - AcBinary
|
||||
private byte[] _acBinaryWithRef = null!;
|
||||
private byte[] _acBinaryNoRef = null!;
|
||||
|
||||
// Serialized data - MessagePack
|
||||
private byte[] _msgPackData = null!;
|
||||
private byte[] _bsonData = null!;
|
||||
|
||||
// Options
|
||||
private AcBinarySerializerOptions _withRefOptions = null!;
|
||||
private AcBinarySerializerOptions _noRefOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
// Create test data with shared references
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
_testOrder = TestDataFactory.CreateOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 4,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedMetadata: sharedMeta);
|
||||
|
||||
// Setup options - WithRef uses Default (which has reference handling), NoRef explicitly disables it
|
||||
_withRefOptions = AcBinarySerializerOptions.Default;
|
||||
_noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
// Serialize with different options
|
||||
_acBinaryWithRef = AcBinarySerializer.Serialize(_testOrder, _withRefOptions);
|
||||
_acBinaryNoRef = AcBinarySerializer.Serialize(_testOrder, _noRefOptions);
|
||||
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
|
||||
|
||||
// BSON
|
||||
try
|
||||
{
|
||||
_bsonData = _testOrder.ToBsonDocument().ToBson();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_bsonData = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// Create populate target
|
||||
_populateTarget = new TestOrder { Id = _testOrder.Id };
|
||||
foreach (var item in _testOrder.Items)
|
||||
{
|
||||
_populateTarget.Items.Add(new TestOrderItem { Id = item.Id });
|
||||
}
|
||||
|
||||
// Print size comparison
|
||||
PrintSizeComparison();
|
||||
}
|
||||
|
||||
private void PrintSizeComparison()
|
||||
{
|
||||
Console.WriteLine("\n" + new string('=', 60));
|
||||
Console.WriteLine("?? SIZE COMPARISON (AcBinary vs MessagePack vs BSON)");
|
||||
Console.WriteLine(new string('=', 60));
|
||||
Console.WriteLine($" AcBinary WithRef: {_acBinaryWithRef.Length,8:N0} bytes");
|
||||
Console.WriteLine($" AcBinary NoRef: {_acBinaryNoRef.Length,8:N0} bytes");
|
||||
Console.WriteLine($" MessagePack: {_msgPackData.Length,8:N0} bytes");
|
||||
Console.WriteLine($" BSON: {_bsonData.Length,8:N0} bytes");
|
||||
Console.WriteLine(new string('-', 60));
|
||||
Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryWithRef.Length / Math.Max(1, _msgPackData.Length):F1}% (WithRef)");
|
||||
Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryNoRef.Length / Math.Max(1, _msgPackData.Length):F1}% (NoRef)");
|
||||
Console.WriteLine(new string('=', 60) + "\n");
|
||||
}
|
||||
|
||||
#region Serialize Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize WithRef")]
|
||||
public byte[] Serialize_AcBinary_WithRef() => AcBinarySerializer.Serialize(_testOrder, _withRefOptions);
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize NoRef")]
|
||||
public byte[] Serialize_AcBinary_NoRef() => AcBinarySerializer.Serialize(_testOrder, _noRefOptions);
|
||||
|
||||
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
|
||||
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "BSON Serialize")]
|
||||
public byte[] Serialize_Bson() => _testOrder.ToBsonDocument().ToBson();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deserialize Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize WithRef")]
|
||||
public TestOrder? Deserialize_AcBinary_WithRef() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryWithRef);
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize NoRef")]
|
||||
public TestOrder? Deserialize_AcBinary_NoRef() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryNoRef);
|
||||
|
||||
[Benchmark(Description = "MessagePack Deserialize")]
|
||||
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "BSON Deserialize")]
|
||||
public TestOrder? Deserialize_Bson()
|
||||
{
|
||||
if (_bsonData == null || _bsonData.Length == 0) return null;
|
||||
using var ms = new MemoryStream(_bsonData);
|
||||
using var reader = new BsonBinaryReader(ms);
|
||||
return BsonSerializer.Deserialize<TestOrder>(reader);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Populate Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcBinary Populate WithRef")]
|
||||
public void Populate_AcBinary_WithRef()
|
||||
{
|
||||
var target = CreatePopulateTarget();
|
||||
AcBinaryDeserializer.Populate(_acBinaryWithRef, target);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary Populate NoRef")]
|
||||
public void Populate_AcBinary_NoRef()
|
||||
{
|
||||
// Create fresh target each time to avoid state accumulation
|
||||
var target = CreatePopulateTarget();
|
||||
AcBinaryDeserializer.Populate(_acBinaryNoRef, target);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary PopulateMerge WithRef")]
|
||||
public void PopulateMerge_AcBinary_WithRef()
|
||||
{
|
||||
var target = CreatePopulateTarget();
|
||||
AcBinaryDeserializer.PopulateMerge(_acBinaryWithRef.AsSpan(), target);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary PopulateMerge NoRef")]
|
||||
public void PopulateMerge_AcBinary_NoRef()
|
||||
{
|
||||
// Create fresh target each time to avoid state accumulation
|
||||
var target = CreatePopulateTarget();
|
||||
AcBinaryDeserializer.PopulateMerge(_acBinaryNoRef.AsSpan(), target);
|
||||
}
|
||||
|
||||
private TestOrder CreatePopulateTarget()
|
||||
{
|
||||
var target = new TestOrder { Id = _testOrder.Id };
|
||||
foreach (var item in _testOrder.Items)
|
||||
{
|
||||
target.Items.Add(new TestOrderItem { Id = item.Id });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed size comparison - not a performance benchmark, just size output.
|
||||
/// Now includes BSON size output and uses AcBinary without reference handling.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
public class SizeComparisonBenchmark
|
||||
{
|
||||
private TestOrder _smallOrder = null!;
|
||||
private TestOrder _mediumOrder = null!;
|
||||
private TestOrder _largeOrder = null!;
|
||||
|
||||
private MessagePackSerializerOptions _msgPackOptions = null!;
|
||||
private AcBinarySerializerOptions _withRefOptions = null!;
|
||||
private AcBinarySerializerOptions _noRefOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
_withRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
|
||||
// Small order
|
||||
TestDataFactory.ResetIdCounter();
|
||||
_smallOrder = TestDataFactory.CreateOrder(itemCount: 1, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2);
|
||||
|
||||
// Medium order
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("Shared");
|
||||
var sharedUser = TestDataFactory.CreateUser("shared");
|
||||
_mediumOrder = TestDataFactory.CreateOrder(
|
||||
itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3,
|
||||
sharedTag: sharedTag, sharedUser: sharedUser);
|
||||
|
||||
// Large order
|
||||
TestDataFactory.ResetIdCounter();
|
||||
sharedTag = TestDataFactory.CreateTag("SharedLarge");
|
||||
sharedUser = TestDataFactory.CreateUser("sharedlarge");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("meta", withChild: true);
|
||||
_largeOrder = TestDataFactory.CreateOrder(
|
||||
itemCount: 5, palletsPerItem: 4, measurementsPerPallet: 3, pointsPerMeasurement: 5,
|
||||
sharedTag: sharedTag, sharedUser: sharedUser, sharedMetadata: sharedMeta);
|
||||
|
||||
PrintDetailedSizeComparison();
|
||||
}
|
||||
|
||||
private void PrintDetailedSizeComparison()
|
||||
{
|
||||
Console.WriteLine("\n" + new string('=', 80));
|
||||
Console.WriteLine("?? DETAILED SIZE COMPARISON: AcBinary vs MessagePack vs BSON");
|
||||
Console.WriteLine(new string('=', 80));
|
||||
|
||||
PrintOrderSize("Small Order (1x1x1x2)", _smallOrder);
|
||||
PrintOrderSize("Medium Order (3x2x2x3) + SharedRefs", _mediumOrder);
|
||||
PrintOrderSize("Large Order (5x4x3x5) + SharedRefs", _largeOrder);
|
||||
|
||||
Console.WriteLine(new string('=', 80) + "\n");
|
||||
}
|
||||
|
||||
private void PrintOrderSize(string name, TestOrder order)
|
||||
{
|
||||
var acWithRef = AcBinarySerializer.Serialize(order, _withRefOptions);
|
||||
var acNoRef = AcBinarySerializer.Serialize(order, _noRefOptions);
|
||||
var msgPack = MessagePackSerializer.Serialize(order, _msgPackOptions);
|
||||
byte[] bson;
|
||||
try { bson = order.ToBsonDocument().ToBson(); } catch { bson = Array.Empty<byte>(); }
|
||||
|
||||
Console.WriteLine($"\n {name}:");
|
||||
Console.WriteLine($" AcBinary WithRef: {acWithRef.Length,8:N0} bytes ({100.0 * acWithRef.Length / Math.Max(1, msgPack.Length),5:F1}% of MsgPack)");
|
||||
Console.WriteLine($" AcBinary NoRef: {acNoRef.Length,8:N0} bytes ({100.0 * acNoRef.Length / Math.Max(1, msgPack.Length),5:F1}% of MsgPack)");
|
||||
Console.WriteLine($" MessagePack: {msgPack.Length,8:N0} bytes (100.0%)");
|
||||
Console.WriteLine($" BSON: {bson.Length,8:N0} bytes (compared to MsgPack)");
|
||||
|
||||
var withRefSaving = msgPack.Length - acWithRef.Length;
|
||||
var noRefSaving = msgPack.Length - acNoRef.Length;
|
||||
if (withRefSaving > 0)
|
||||
Console.WriteLine($" ?? AcBinary WithRef saves: {withRefSaving:N0} bytes ({100.0 * withRefSaving / msgPack.Length:F1}%)");
|
||||
else
|
||||
Console.WriteLine($" ?? AcBinary WithRef larger by: {-withRefSaving:N0} bytes");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Placeholder")]
|
||||
public int Placeholder() => 1; // Just to make BenchmarkDotNet happy
|
||||
}
|
||||
|
||||
public enum BinaryBenchmarkMode
|
||||
{
|
||||
Default,
|
||||
NoReferenceHandling,
|
||||
FastMode
|
||||
}
|
||||
|
||||
public abstract class AcBinaryOptionsBenchmarkBase
|
||||
{
|
||||
protected TestOrder TestOrder = null!;
|
||||
protected AcBinarySerializerOptions BinaryOptions = null!;
|
||||
protected MessagePackSerializerOptions MsgPackOptions = null!;
|
||||
protected byte[] AcBinaryData = null!;
|
||||
protected byte[] MsgPackData = null!;
|
||||
|
||||
[Params(BinaryBenchmarkMode.Default, BinaryBenchmarkMode.NoReferenceHandling, BinaryBenchmarkMode.FastMode)]
|
||||
public BinaryBenchmarkMode Mode { get; set; }
|
||||
|
||||
[GlobalSetup]
|
||||
public void GlobalSetup()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
TestOrder = TestDataFactory.CreateBenchmarkOrder(
|
||||
itemCount: 4,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 6);
|
||||
|
||||
BinaryOptions = CreateBinaryOptions(Mode);
|
||||
MsgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
AcBinaryData = AcBinarySerializer.Serialize(TestOrder, BinaryOptions);
|
||||
MsgPackData = MessagePackSerializer.Serialize(TestOrder, MsgPackOptions);
|
||||
|
||||
var ratio = MsgPackData.Length == 0 ? 0 : 100.0 * AcBinaryData.Length / MsgPackData.Length;
|
||||
Console.WriteLine($"[BenchmarkSetup] Mode={Mode} | AcBinary={AcBinaryData.Length} bytes | MessagePack={MsgPackData.Length} bytes | Ratio={ratio:F1}%");
|
||||
}
|
||||
|
||||
private static AcBinarySerializerOptions CreateBinaryOptions(BinaryBenchmarkMode mode) => mode switch
|
||||
{
|
||||
BinaryBenchmarkMode.Default => new AcBinarySerializerOptions(),
|
||||
BinaryBenchmarkMode.NoReferenceHandling => AcBinarySerializerOptions.WithoutReferenceHandling,
|
||||
BinaryBenchmarkMode.FastMode => new AcBinarySerializerOptions
|
||||
{
|
||||
UseMetadata = false,
|
||||
UseStringInterning = StringInterningMode.None,
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
},
|
||||
_ => new AcBinarySerializerOptions()
|
||||
};
|
||||
}
|
||||
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class AcBinaryOptionsSerializeBenchmark : AcBinaryOptionsBenchmarkBase
|
||||
{
|
||||
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
|
||||
public byte[] Serialize_MessagePack() => MessagePackSerializer.Serialize(TestOrder, MsgPackOptions);
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize")]
|
||||
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(TestOrder, BinaryOptions);
|
||||
}
|
||||
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class AcBinaryOptionsDeserializeBenchmark : AcBinaryOptionsBenchmarkBase
|
||||
{
|
||||
[Benchmark(Description = "MessagePack Deserialize", Baseline = true)]
|
||||
public TestOrder? Deserialize_MessagePack() => MessagePackSerializer.Deserialize<TestOrder>(MsgPackData, MsgPackOptions);
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize")]
|
||||
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(AcBinaryData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Large-scale benchmark simulating production workloads.
|
||||
/// Tests with ~50,000+ IId objects with deep hierarchy and shared references.
|
||||
/// This is closer to real-world scenarios with 2200 root items and 4-5MB binary data.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class LargeScaleBinaryBenchmark
|
||||
{
|
||||
// Test data - smaller scale for benchmark (500 items ? 25K objects)
|
||||
// Production would be 2200 items ? 100K+ objects
|
||||
private TestOrder _testOrder = null!;
|
||||
private TestOrder _populateTarget = null!;
|
||||
|
||||
// Serialized data
|
||||
private byte[] _acBinaryData = null!;
|
||||
private byte[] _msgPackData = null!;
|
||||
|
||||
// Options
|
||||
private AcBinarySerializerOptions _binaryOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackOptions = null!;
|
||||
|
||||
private int _objectCount;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
Console.WriteLine("Creating large-scale test data...");
|
||||
|
||||
// Use 500 root items for benchmark (?25K objects)
|
||||
// Production would use 2200 (?100K+ objects)
|
||||
const int rootItems = 500;
|
||||
const int pallets = 3;
|
||||
const int measurements = 3;
|
||||
const int points = 4;
|
||||
|
||||
_objectCount = TestDataFactory.CalculateObjectCount(rootItems, pallets, measurements, points);
|
||||
Console.WriteLine($"Creating ~{_objectCount:N0} IId objects...");
|
||||
|
||||
_testOrder = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItems, pallets, measurements, points);
|
||||
Console.WriteLine($"Created order with {_testOrder.Items.Count} root items");
|
||||
|
||||
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
Console.WriteLine("Serializing AcBinary...");
|
||||
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
|
||||
|
||||
Console.WriteLine("Serializing MessagePack...");
|
||||
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
|
||||
|
||||
// Create populate target
|
||||
_populateTarget = new TestOrder { Id = _testOrder.Id };
|
||||
foreach (var item in _testOrder.Items.Take(10)) // Only first 10 for populate target
|
||||
{
|
||||
_populateTarget.Items.Add(new TestOrderItem { Id = item.Id });
|
||||
}
|
||||
|
||||
PrintStats();
|
||||
}
|
||||
|
||||
private void PrintStats()
|
||||
{
|
||||
Console.WriteLine("\n" + new string('=', 70));
|
||||
Console.WriteLine("?? LARGE-SCALE BENCHMARK STATS");
|
||||
Console.WriteLine(new string('=', 70));
|
||||
Console.WriteLine($" Root Items: {_testOrder.Items.Count:N0}");
|
||||
Console.WriteLine($" Total Objects: ~{_objectCount:N0} IId objects");
|
||||
Console.WriteLine($" AcBinary Size: {_acBinaryData.Length:N0} bytes ({_acBinaryData.Length / 1024.0 / 1024.0:F2} MB)");
|
||||
Console.WriteLine($" MsgPack Size: {_msgPackData.Length:N0} bytes ({_msgPackData.Length / 1024.0 / 1024.0:F2} MB)");
|
||||
Console.WriteLine($" Size Ratio: {100.0 * _acBinaryData.Length / _msgPackData.Length:F1}% of MsgPack");
|
||||
Console.WriteLine(new string('=', 70) + "\n");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "LargeScale AcBinary Deserialize")]
|
||||
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
|
||||
|
||||
[Benchmark(Description = "LargeScale MsgPack Deserialize", Baseline = true)]
|
||||
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "LargeScale AcBinary Serialize")]
|
||||
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
|
||||
|
||||
[Benchmark(Description = "LargeScale MsgPack Serialize")]
|
||||
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AcJson vs System.Text.Json comparison - measures Newtonsoft.Json based AcJson against modern STJ.
|
||||
/// Uses simple flat object (PrimitiveTestClass) to avoid circular reference issues.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class AcJsonVsSystemTextJsonBenchmark
|
||||
{
|
||||
private PrimitiveTestClass _testData = null!;
|
||||
private string _acJsonData = null!;
|
||||
private string _stjData = null!;
|
||||
private AcJsonSerializerOptions _acJsonOptions = null!;
|
||||
private JsonSerializerOptions _stjOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
Console.WriteLine("Creating test data for AcJson vs System.Text.Json...");
|
||||
|
||||
// Use simple flat object to avoid circular reference issues
|
||||
_testData = TestDataFactory.CreatePrimitiveTestData();
|
||||
|
||||
// Setup options
|
||||
_acJsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
|
||||
_stjOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
ReferenceHandler = null // No reference handling
|
||||
};
|
||||
|
||||
// Pre-serialize
|
||||
_acJsonData = AcJsonSerializer.Serialize(_testData, _acJsonOptions);
|
||||
_stjData = JsonSerializer.Serialize(_testData, _stjOptions);
|
||||
|
||||
Console.WriteLine($"AcJson size: {_acJsonData.Length:N0} chars");
|
||||
Console.WriteLine($"STJ size: {_stjData.Length:N0} chars");
|
||||
Console.WriteLine($"Size ratio: {100.0 * _acJsonData.Length / _stjData.Length:F1}%");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcJson Serialize", Baseline = true)]
|
||||
public string Serialize_AcJson() =>
|
||||
AcJsonSerializer.Serialize(_testData, _acJsonOptions);
|
||||
|
||||
[Benchmark(Description = "System.Text.Json Serialize")]
|
||||
public string Serialize_STJ() =>
|
||||
JsonSerializer.Serialize(_testData, _stjOptions);
|
||||
|
||||
[Benchmark(Description = "AcJson Deserialize")]
|
||||
public PrimitiveTestClass? Deserialize_AcJson() =>
|
||||
AcJsonDeserializer.Deserialize<PrimitiveTestClass>(_acJsonData, _acJsonOptions);
|
||||
|
||||
[Benchmark(Description = "System.Text.Json Deserialize")]
|
||||
public PrimitiveTestClass? Deserialize_STJ() =>
|
||||
JsonSerializer.Deserialize<PrimitiveTestClass>(_stjData, _stjOptions);
|
||||
}
|
||||
|
|
@ -107,18 +107,18 @@ public class SignalRCommunicationBenchmarks
|
|||
|
||||
[Benchmark(Description = "Server: Deserialize complex OrderItem")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public TestOrderItem Server_DeserializeComplexOrderItem()
|
||||
public TestOrderItem_All_True Server_DeserializeComplexOrderItem()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderItemMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrderItem>()!;
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrderItem_All_True>()!;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize complex Order")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public TestOrder Server_DeserializeComplexOrder()
|
||||
public TestOrder_All_True Server_DeserializeComplexOrder()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrder>()!;
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -144,10 +144,10 @@ public class SignalRCommunicationBenchmarks
|
|||
|
||||
[Benchmark(Description = "Client: Deserialize complex Order response")]
|
||||
[BenchmarkCategory("Client", "Response")]
|
||||
public TestOrder? Client_DeserializeOrderResponse()
|
||||
public TestOrder_All_True? Client_DeserializeOrderResponse()
|
||||
{
|
||||
var response = SignalRMessageFactory.DeserializeResponse(_complexResponseMessage);
|
||||
return response?.ResponseData?.JsonTo<TestOrder>();
|
||||
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -171,19 +171,19 @@ public class SignalRCommunicationBenchmarks
|
|||
|
||||
[Benchmark(Description = "Full: Complex Order round-trip")]
|
||||
[BenchmarkCategory("Full")]
|
||||
public TestOrder? Full_ComplexOrderRoundTrip()
|
||||
public TestOrder_All_True? Full_ComplexOrderRoundTrip()
|
||||
{
|
||||
// Client creates message
|
||||
var requestBytes = SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
|
||||
// Server deserializes
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.ContractlessOptions);
|
||||
var order = postMessage.PostDataJson!.JsonTo<TestOrder>()!;
|
||||
var order = postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
|
||||
// Server modifies and creates response
|
||||
order.OrderNumber = "PROCESSED-" + order.OrderNumber;
|
||||
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, order);
|
||||
// Client deserializes response
|
||||
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
|
||||
return response?.ResponseData?.JsonTo<TestOrder>();
|
||||
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -24,9 +24,9 @@ public class SignalRRoundTripBenchmarks
|
|||
private BenchmarkSignalRService _service = null!;
|
||||
|
||||
// Pre-created test data
|
||||
private TestOrderItem _testOrderItem = null!;
|
||||
private TestOrder _testOrder = null!;
|
||||
private SharedTag _sharedTag = null!;
|
||||
private TestOrderItem_All_True _testOrderItem = null!;
|
||||
private TestOrder_All_True _testOrder = null!;
|
||||
private SharedTag_All_True _sharedTag = null!;
|
||||
private int[] _intArray = null!;
|
||||
private List<string> _stringList = null!;
|
||||
private Guid _testGuid;
|
||||
|
|
@ -41,9 +41,9 @@ public class SignalRRoundTripBenchmarks
|
|||
_hub.RegisterService(_service, _client);
|
||||
|
||||
// Pre-create test data
|
||||
_testOrderItem = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
_testOrderItem = new TestOrderItem_All_True { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
_testOrder = TestDataFactory.CreateOrder(itemCount: 3);
|
||||
_sharedTag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" };
|
||||
_sharedTag = new SharedTag_All_True { Id = 1, Name = "Important", Color = "#FF0000" };
|
||||
_intArray = [1, 2, 3, 4, 5];
|
||||
_stringList = ["apple", "banana", "cherry"];
|
||||
_testGuid = Guid.NewGuid();
|
||||
|
|
@ -104,25 +104,25 @@ public class SignalRRoundTripBenchmarks
|
|||
|
||||
#region Complex Object Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrderItem")]
|
||||
[Benchmark(Description = "RoundTrip: TestOrderItem_All_True")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrderItem? RoundTrip_TestOrderItem()
|
||||
public TestOrderItem_All_True? RoundTrip_TestOrderItem()
|
||||
{
|
||||
return _client.PostDataSync<TestOrderItem, TestOrderItem>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
|
||||
return _client.PostDataSync<TestOrderItem_All_True, TestOrderItem_All_True>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrder (3 items)")]
|
||||
[Benchmark(Description = "RoundTrip: TestOrder_All_True (3 items)")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrder? RoundTrip_TestOrder()
|
||||
public TestOrder_All_True? RoundTrip_TestOrder()
|
||||
{
|
||||
return _client.PostDataSync<TestOrder, TestOrder>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
|
||||
return _client.PostDataSync<TestOrder_All_True, TestOrder_All_True>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: SharedTag")]
|
||||
[Benchmark(Description = "RoundTrip: SharedTag_All_True")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public SharedTag? RoundTrip_SharedTag()
|
||||
public SharedTag_All_True? RoundTrip_SharedTag()
|
||||
{
|
||||
return _client.PostDataSync<SharedTag, SharedTag>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
|
||||
return _client.PostDataSync<SharedTag_All_True, SharedTag_All_True>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -212,16 +212,16 @@ public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServ
|
|||
public TResponse? GetAllSync<TResponse>(int tag)
|
||||
=> GetAllAsync<TResponse>(tag).GetAwaiter().GetResult();
|
||||
|
||||
protected override Task MessageReceived(int messageTag, byte[] messageBytes) => Task.CompletedTask;
|
||||
protected override Task MessageReceived(int messageTag, SignalParams signalParams, object data) => Task.CompletedTask;
|
||||
protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected;
|
||||
protected override bool IsConnected() => true;
|
||||
protected override Task StartConnectionInternal() => Task.CompletedTask;
|
||||
protected override Task StopConnectionInternal() => Task.CompletedTask;
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, object? data)
|
||||
{
|
||||
await _hub.OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
await _hub.OnReceiveMessage(messageTag, requestId, signalParams, data ?? Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,8 +247,8 @@ public class BenchmarkSignalRHub : AcWebSignalRHubBase<BenchmarkSignalRTags, Tes
|
|||
protected override string? GetUserIdentifier() => "benchmark-user";
|
||||
protected override ClaimsPrincipal? GetUser() => null;
|
||||
|
||||
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
|
||||
=> SendMessageToClient(_callerClient, messageTag, message, requestId);
|
||||
protected override Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
|
||||
=> SendMessageToClient(_callerClient, messageTag, status, responseData, requestId, clientSignalParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -278,7 +278,7 @@ public class BenchmarkSignalRService
|
|||
public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem HandleTestOrderItem(TestOrderItem item) => new()
|
||||
public TestOrderItem_All_True HandleTestOrderItem(TestOrderItem_All_True item) => new()
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
|
|
@ -287,10 +287,10 @@ public class BenchmarkSignalRService
|
|||
};
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderParam)]
|
||||
public TestOrder HandleTestOrder(TestOrder order) => order;
|
||||
public TestOrder_All_True HandleTestOrder(TestOrder_All_True order) => order;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.SharedTagParam)]
|
||||
public SharedTag HandleSharedTag(SharedTag tag) => tag;
|
||||
public SharedTag_All_True HandleSharedTag(SharedTag_All_True tag) => tag;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray();
|
||||
|
|
@ -299,7 +299,7 @@ public class BenchmarkSignalRService
|
|||
public List<string> HandleStringList(List<string> items) => items.Select(x => x.ToUpper()).ToList();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem item) => $"{id}-{item?.ProductName}";
|
||||
public string HandleIntAndDto(int id, TestOrderItem_All_True item) => $"{id}-{item?.ProductName}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.FiveParams)]
|
||||
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) => $"{a}-{b}-{c}-{d}-{e}";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// AcBinary benchmark, Byte[] I/O mode. The headline AcBinary row in every cell — compared
|
||||
/// against <see cref="MemoryPackBenchmark{T}"/> as the SOTA baseline.
|
||||
/// </summary>
|
||||
public sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options);
|
||||
|
||||
public AcBinaryBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
|
||||
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks AcBinary via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
|
||||
/// Realistic IBufferWriter usage pattern: caller owns + reuses the writer (zero alloc per call after warmup).
|
||||
/// </summary>
|
||||
public sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
private readonly ArrayBufferWriter<byte> _bufferWriter;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrReuse;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options);
|
||||
|
||||
public AcBinaryBufferWriterBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||||
|
||||
// Measure ONLY the BufferWriter infrastructure setup on the serialize side (excluding the
|
||||
// helper Serialize above). Deserialize side reads directly from `_serialized` byte[] — no
|
||||
// dedicated setup allocation, hence SetupDeserializeAllocBytes = 0.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
|
||||
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSetup - beforeSetup;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount(); // reuse — no alloc, no zeroing
|
||||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> (the ROS overload), NOT from byte[] —
|
||||
// single-segment array-backed sequence triggers the fast-path in AcBinaryDeserializer.cs:298 which
|
||||
// redirects to the byte[] overload. This means the bench actually exercises the ROS-input path
|
||||
// (the production-realistic surface for SignalR / Pipe consumers) rather than secretly testing
|
||||
// byte[] Deser under the BufWr label.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount();
|
||||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||||
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks AcBinary via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||||
/// One-shot scenario — represents code that doesn't reuse a writer across calls.
|
||||
/// Uses BufferWriterChunkSize=4096 (production-realistic, SignalR-aligned) instead of the 65535 default —
|
||||
/// otherwise AcBinary would request 64KB upfront via GetSpan(), forcing the fresh ABW to allocate 64KB
|
||||
/// regardless of payload size (heavy over-allocation for small payloads).
|
||||
/// </summary>
|
||||
public sealed class AcBinaryFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrNew;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B");
|
||||
|
||||
public AcBinaryFreshBufferWriterBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
|
||||
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
|
||||
// size in CreateSerializers only.
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>(); // FRESH every call — alloc + grow as needed
|
||||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> (the ROS overload), NOT from byte[] —
|
||||
// single-segment array-backed sequence triggers the fast-path in AcBinaryDeserializer.cs:298 which
|
||||
// redirects to the byte[] overload. This means the bench actually exercises the ROS-input path
|
||||
// (the production-realistic surface for SignalR / Pipe consumers) rather than secretly testing
|
||||
// byte[] Deser under the BufWr label.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>();
|
||||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Same chunked-framed AsyncPipe code path as <see cref="AcBinaryNamedPipeBenchmark{T}"/>, but the transport
|
||||
/// is an in-memory <see cref="System.IO.Pipelines.Pipe"/> instead of a kernel <c>NamedPipe</c>. The Pipe's
|
||||
/// <c>Writer</c>/<c>Reader</c> pair is a managed-only zero-copy slab handoff — no syscalls, no kernel
|
||||
/// buffer copy, no IRP queueing.
|
||||
///
|
||||
/// <para><b>Why this benchmark matters</b>: by holding ALL other variables constant (same SerializeChunkedFramed,
|
||||
/// same AsyncPipeReaderInput, same drain task, same consumer task, same multi-message wire format), this
|
||||
/// row isolates the <b>kernel-NamedPipe transport overhead</b> from the chunked-streaming framework's pure
|
||||
/// CPU cost. The expected delta vs <see cref="AcBinaryNamedPipeBenchmark{T}"/>: per-chunk overhead drops from
|
||||
/// ~25-30 µs (kernel-syscall pair + IRP) to ~1-2 µs (managed slab handoff). Multi-chunk Large-message rows
|
||||
/// should converge dramatically toward <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/>.</para>
|
||||
///
|
||||
/// <para><b>Real-world relevance</b>: in-memory Pipe is the typical primitive used for cross-thread serializer
|
||||
/// pipelines inside a single process (e.g. SignalR's Kestrel transport adapter, gRPC framework internals,
|
||||
/// custom message brokers). The numbers from this row reflect that scenario, NOT the kernel-pipe loopback
|
||||
/// of the NamedPipe benchmark.</para>
|
||||
/// </summary>
|
||||
public sealed class AcBinaryInMemoryPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||||
|
||||
// Long-lived in-memory pipe lifecycle (set up once in ctor — NOT timed).
|
||||
private readonly Pipe _pipe;
|
||||
private readonly PipeWriter _pipeWriter;
|
||||
private readonly PipeReader _pipeReader;
|
||||
|
||||
// Long-lived multi-message receive infrastructure (set up once in ctor) — same pattern as the NamedPipe
|
||||
// variant: drain pumps reader into AsyncPipeReaderInput, consumer task drives Deserialize<T>(input).
|
||||
private readonly AsyncPipeReaderInput _input;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _drainTask;
|
||||
private readonly Task _consumerTask;
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private object? _lastResult;
|
||||
private bool _captureResult;
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.InMemoryPipe;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=Pipe(in-memory,multiMessage,2-task)");
|
||||
|
||||
public AcBinaryInMemoryPipeBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// In-memory Pipe construction. NO kernel-pipe pair, NO Connect handshake — just a managed Pipe object
|
||||
// and a reference to its Writer side. PipeWriterImpl (parallel-flush capable, NOT StreamPipeWriter).
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
_pipe = new Pipe();
|
||||
_pipeWriter = _pipe.Writer;
|
||||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// PipeReader reference + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain task +
|
||||
// consumer task scaffolding. Identical to the NamedPipe variant on the receive side.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
_pipeReader = _pipe.Reader;
|
||||
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
|
||||
_cts = new CancellationTokenSource();
|
||||
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
|
||||
_consumerTask = Task.Run(ConsumeLoop);
|
||||
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
|
||||
// Mirror of AcBinaryNamedPipeBenchmark.ConsumeLoop — same pattern, same MRES protocol.
|
||||
private void ConsumeLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(_input, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — see ConsumeLoop in NamedPipe variant for rationale.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// Same 2-task streaming pipeline as NamedPipe variant — only the transport differs (in-memory Pipe
|
||||
// instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task
|
||||
// reads from PipeReader → input.Feed → consumer Deserialize<T> consumes byte-by-byte.
|
||||
//
|
||||
// Uses the Pipe-overload (instead of the PipeWriter-overload) so the FlushPolicy parameter is
|
||||
// exposed for tuning. Toggle between FlushPolicy.PerChunk (bounded peak memory, per-chunk await
|
||||
// FlushAsync) and FlushPolicy.Coalesced (fire-and-forget per chunk, pipe-coalesced flushes up to
|
||||
// PauseWriterThreshold ~64 KB) to A/B-test the streaming-pipeline overhead. FlushPolicy.PerChunk
|
||||
// is functionally equivalent to the PipeWriter-overload (both internally route to
|
||||
// SerializeToPipeWriterCore with FlushPolicy.PerChunk).
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
AcBinarySerializer.SerializeChunkedFramed(_order, _pipe, _options, FlushPolicy.Coalesced);
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
// Complete writer + reader (in-memory Pipe — no underlying stream to dispose).
|
||||
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
|
||||
try { _input.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Raw <c>byte[]</c> over an in-memory cross-thread handoff — NO transport (no NamedPipe, no Pipe, no
|
||||
/// Channel<see langword="<T>"/>). Calling thread serialises into a fresh <c>byte[]</c>, hands it to a
|
||||
/// background consumer task via a single byte[] slot + MRES pair; the consumer deserialises and signals done.
|
||||
///
|
||||
/// <para><b>Why this benchmark matters</b>: completes the 2x2 transport × wire-format matrix:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>NamedPipe + Chunked</b> = <see cref="AcBinaryNamedPipeBenchmark{T}"/></description></item>
|
||||
/// <item><description><b>NamedPipe + Raw</b> = <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/></description></item>
|
||||
/// <item><description><b>In-memory Pipe + Chunked</b> = <see cref="AcBinaryInMemoryPipeBenchmark{T}"/></description></item>
|
||||
/// <item><description><b>In-memory + Raw</b> = THIS row — apples-to-apples baseline for the in-memory chunked row</description></item>
|
||||
/// </list>
|
||||
/// <para>Side-by-side with <see cref="AcBinaryInMemoryPipeBenchmark{T}"/> this isolates the chunked-streaming
|
||||
/// framework's pure CPU cost, with the same in-memory transport (zero kernel involvement) on both sides.
|
||||
/// Side-by-side with <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/> this isolates the kernel-NamedPipe
|
||||
/// overhead on the raw-byte[] side.</para>
|
||||
/// </summary>
|
||||
public sealed class AcBinaryInMemoryRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||||
|
||||
// Long-lived consumer-task infrastructure (Deserialize on BG thread, signaled per iter).
|
||||
// No transport — just a byte[] slot for handoff between calling thread and consumer task.
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _consumerTask;
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private byte[]? _pendingBytes; // calling thread → consumer task handoff slot
|
||||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||||
private bool _captureResult;
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.InMemoryRaw;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=in-memory(raw,2-task)");
|
||||
|
||||
public AcBinaryInMemoryRawByteArrayBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// Nothing to set up — calling thread allocates byte[] per iter via AcBinarySerializer.Serialize.
|
||||
SetupSerializeAllocBytes = 0;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// 1× background consumer-task + 2× MRES (request / done) + cancellation source.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
_cts = new CancellationTokenSource();
|
||||
_consumerTask = Task.Run(ConsumerLoop);
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, picks up the byte[] from _pendingBytes, runs Deserialize<T>(bytes),
|
||||
// signals _consumeDone. Direct in-process handoff — no transport syscall, no buffer copy beyond the byte[]
|
||||
// reference itself (zero-copy by reference).
|
||||
private void ConsumerLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = _pendingBytes;
|
||||
if (bytes != null)
|
||||
{
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — see ConsumerLoop in NamedPipe variant for rationale.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// 2-task in-memory pipeline:
|
||||
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
|
||||
// 2. Calling thread parks the byte[] into _pendingBytes and signals consumer task. Consumer task
|
||||
// picks up the reference (zero-copy) and runs Deserialize<T>(bytes).
|
||||
// 3. Calling thread waits for _consumeDone (consumer task finished Des).
|
||||
//
|
||||
// Same architectural limitation as the NamedPipe-raw variant: Des cannot start until full bytes
|
||||
// are available. Only the per-iter Ser↔Des thread-handoff overlaps slightly (calling thread starts
|
||||
// signalling and waiting while consumer thread takes the byte[]).
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
_pendingBytes = bytes;
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO.Pipelines;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API
|
||||
/// (<see cref="AcBinarySerializer.SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
|
||||
/// + <see cref="AsyncPipeReaderInput"/> + <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>).
|
||||
/// Mirrors what a real consumer (e.g. <c>DeserializeFromPipeReaderAsync</c>) does per message:
|
||||
/// long-lived <see cref="AsyncPipeReaderInput"/> with multi-message wire framing on top of a long-lived NamedPipe.
|
||||
///
|
||||
/// <para><b>Architecture</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Constructor (NOT timed): sets up <see cref="NamedPipeServerStream"/> + <see cref="NamedPipeClientStream"/>,
|
||||
/// waits for connection, creates one long-lived <see cref="System.IO.Pipelines.PipeWriter"/> /
|
||||
/// <see cref="System.IO.Pipelines.PipeReader"/> pair, ONE long-lived <see cref="AsyncPipeReaderInput"/>
|
||||
/// in <c>multiMessage = true</c> mode, ONE drain Task that pumps <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>
|
||||
/// forever, and ONE deserialize Task that loops <c>AcBinaryDeserializer.Deserialize<T>(input, opts)</c>
|
||||
/// producing into a <see cref="System.Threading.Channels.Channel{T}"/>.</item>
|
||||
/// <item>Per-iteration <see cref="Serialize"/> (timed): sender writes via
|
||||
/// <see cref="AcBinarySerializer.SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
|
||||
/// — multi-message wire (<c>[201][UINT16][data]...[202]</c>); the <c>[202]</c> end marker arms the input's
|
||||
/// <c>_readPos = -1</c> sentinel, so the next message's first <c>AppendToBuffer</c> recycles the buffer to 0.
|
||||
/// Then receiver awaits the channel for the deserialized result.</item>
|
||||
/// <item><see cref="Deserialize"/> is a no-op (full round-trip captured in <see cref="Serialize"/>);
|
||||
/// <see cref="IsRoundTripOnly"/>=true → Ser ms / SerAlloc oszlopok N/A, RT ms = full round-trip.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Per-iter overhead</b>: 0 new <c>Task.Run</c>, 0 new <c>AsyncPipeReaderInput</c>, 0 new <c>CancellationTokenSource</c>.
|
||||
/// Pure cost = <c>SerializeChunkedFramed</c> (CPU + chunk-onkénti flush) + kernel write/read syscalls + 1 sync barrier
|
||||
/// (channel) + deserialized graph alloc. The "multi-message reuse" pattern enabled by Q4T8 fix (R5K2 minimum: <c>_readPos = -1</c>
|
||||
/// sentinel + <c>AppendToBuffer</c> sliding-window cycling).</para>
|
||||
///
|
||||
/// <para><b>Approximation note</b>: single-process loopback NamedPipe. Real cross-process / cross-machine SignalR
|
||||
/// adds further transport latency (TCP, WebSocket framing) on top. The benchmark gives a lower bound.</para>
|
||||
/// </summary>
|
||||
public sealed class AcBinaryNamedPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||||
|
||||
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
|
||||
private readonly NamedPipeServerStream _pipeServer;
|
||||
private readonly NamedPipeClientStream _pipeClient;
|
||||
private readonly PipeWriter _pipeWriter;
|
||||
private readonly PipeReader _pipeReader;
|
||||
|
||||
// Long-lived multi-message receive infrastructure (set up once in ctor).
|
||||
private readonly AsyncPipeReaderInput _input;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _drainTask; // BG: PipeReader → input.Feed (continuous pump)
|
||||
private readonly Task _consumerTask; // BG: per-iter Deserialize<T>(input) loop, signaled by calling thread
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||||
private bool _captureResult; // toggle: when true, ConsumeLoop stores result; otherwise discards
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.NamedPipe;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(long-lived,multiMessage,2-task)");
|
||||
|
||||
public AcBinaryNamedPipeBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
|
||||
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
|
||||
// size in CreateSerializers only.
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
// 1× pipe setup. Kernel-side pipe buffer (inBufferSize / outBufferSize on the server ctor — the
|
||||
// client inherits the server-defined buffer size at connect time) matches BufferWriterChunkSize
|
||||
// exactly: AsyncPipeWriterOutput now treats chunkSize as the chunk-on-wire total size (header +
|
||||
// data), so one WriteFile(chunkSize) syscall lands in exactly one kernel-page slot — page-aligned,
|
||||
// no fragmentation, no IRP reordering. _options.BufferWriterChunkSize is the single tunable source.
|
||||
var pipeName = $"AcBinaryBench-{Guid.NewGuid():N}";
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// pipe-pair (server + client) + connect handshake + writer-side PipeWriter wrapper.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous,
|
||||
inBufferSize: _options.BufferWriterChunkSize,
|
||||
outBufferSize: _options.BufferWriterChunkSize);
|
||||
|
||||
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
var serverWait = _pipeServer.WaitForConnectionAsync();
|
||||
_pipeClient.Connect();
|
||||
serverWait.GetAwaiter().GetResult();
|
||||
|
||||
_pipeWriter = PipeWriter.Create(_pipeClient);
|
||||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// PipeReader wrapper + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain
|
||||
// task + consumer task scaffolding. Two long-lived BG tasks total: drain pumps bytes from the
|
||||
// kernel pipe into input; consumer drives Deserialize<T>(input) per iter on signal.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
_pipeReader = PipeReader.Create(_pipeServer);
|
||||
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
// Drain task: pumps PipeReader → input.Feed forever (or until cancel). Single Task.Run for
|
||||
// the full benchmark lifetime — its overhead is amortised across all messages.
|
||||
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
|
||||
|
||||
// Consumer task: per-iter Deserialize<T>(input) loop. Started here once; signaled per-iter via
|
||||
// _consumeRequest. Enables Ser↔Des streaming overlap — calling thread runs SerializeChunkedFramed
|
||||
// while THIS task simultaneously runs Deserialize<T>, both consuming/producing through the
|
||||
// sliding-window buffer pipelined by the drain task.
|
||||
_consumerTask = Task.Run(ConsumeLoop);
|
||||
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
|
||||
// The Deserialize call internally blocks on the input's MRES whenever the drain hasn't yet fed enough
|
||||
// bytes for the next read — that's where the streaming-pipeline overlap with the calling thread (Ser)
|
||||
// happens.
|
||||
private void ConsumeLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(_input, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
|
||||
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// 2-task streaming pipeline:
|
||||
// 1. Calling thread signals consumer task to begin Deserialize<T>(input). Consumer immediately
|
||||
// starts; first read blocks on input's MRES because no bytes flowed yet.
|
||||
// 2. Calling thread starts SerializeChunkedFramed → chunks flow through PipeWriter → kernel pipe →
|
||||
// drain task (BG) feeds input.Feed → MRES pulses → consumer's Deserialize<T> consumes bytes
|
||||
// chunk by chunk. Ser↔Des truly overlap here.
|
||||
// 3. Calling thread waits for _consumeDone (signaling Deserialize<T> returned).
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
// Complete writer + dispose pipe lifecycle.
|
||||
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
|
||||
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _input.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Raw <c>byte[]</c> over a long-lived NamedPipe — NO chunk-framing, NO <c>AsyncPipeReaderInput</c>,
|
||||
/// NO sliding-window buffer. Calling thread serialises + writes; a long-lived background consumer task
|
||||
/// reads and deserialises. Two-task pattern enables Ser↔Read overlap (kernel-pipe-pipelined) AND
|
||||
/// avoids the kernel-buffer-full deadlock when <c>bytes.Length > inBufferSize</c>.
|
||||
///
|
||||
/// Side-by-side with <see cref="AcBinaryNamedPipeBenchmark{T}"/> (chunked-framed AsyncPipe stack) this
|
||||
/// isolates two cost components on the SAME kernel-pipe transport with the SAME <c>inBufferSize</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>This row vs <see cref="AcBinaryBenchmark{T}"/> (Byte[])</b> — pure kernel-NamedPipe
|
||||
/// overhead (WriteFile / ReadFile syscalls + IRP queueing + buffer-copy + thread-handoff).</description></item>
|
||||
/// <item><description><b>This row vs <see cref="AcBinaryNamedPipeBenchmark{T}"/> (chunked-framed)</b> — pure
|
||||
/// AsyncPipe-framework overhead (chunk header writes + sliding-window <c>Feed</c> + MRES wait inside
|
||||
/// <c>AsyncPipeReaderInput</c>) AND the streaming-pipeline benefit of intra-message Ser↔Des overlap (which
|
||||
/// raw lacks — raw can only Ser↔Read overlap, with Des sequential after Read completes).</description></item>
|
||||
/// </list>
|
||||
/// Per-iter <c>byte[]</c> allocation from <c>AcBinarySerializer.Serialize</c> is part of the cost (matches
|
||||
/// <see cref="AcBinaryBenchmark{T}"/>'s API contract); the receive-side scratch buffer is also allocated per-iter
|
||||
/// on the consumer-task (counted via <c>GC.GetTotalAllocatedBytes</c> in <c>BenchmarkLoop.MeasureAllocationTotal</c>).
|
||||
/// </summary>
|
||||
public sealed class AcBinaryNamedPipeRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting + receive-side size known upfront
|
||||
|
||||
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
|
||||
private readonly NamedPipeServerStream _pipeServer;
|
||||
private readonly NamedPipeClientStream _pipeClient;
|
||||
|
||||
// Long-lived consumer-task infrastructure (Read + Deserialize on BG thread, signaled per iter).
|
||||
// Mirrors AcBinaryNamedPipeBenchmark's drain+consumer pair, but raw byte[] doesn't have an
|
||||
// intermediate sliding-window buffer, so Read+Des happen sequentially in one BG task: Read N bytes
|
||||
// → Deserialize<T>(bytes) → signal done. Calling thread's Ser↔Write overlaps with this BG Read+Des
|
||||
// through kernel-pipe pipelining.
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _consumerTask;
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private int _pendingReadSize;
|
||||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||||
private bool _captureResult; // toggle: when true, ConsumerLoop stores result; otherwise discards
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.NamedPipeRaw;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(raw,2-task)");
|
||||
|
||||
public AcBinaryNamedPipeRawByteArrayBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
// BufferWriterChunkSize comes from the caller — same source-of-truth contract as
|
||||
// AcBinaryNamedPipeBenchmark. The kernel pipe-buffer (inBufferSize) is wired to it so the
|
||||
// raw-vs-chunked comparison runs on identical transport conditions.
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
var pipeName = $"AcBinaryBenchRaw-{Guid.NewGuid():N}";
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// pipe-pair (server + client) + connect handshake. NO PipeWriter wrapper — we use the raw
|
||||
// Stream.Write API directly, matching the no-framing semantics of this benchmark.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous,
|
||||
inBufferSize: _options.BufferWriterChunkSize,
|
||||
outBufferSize: _options.BufferWriterChunkSize);
|
||||
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
var serverWait = _pipeServer.WaitForConnectionAsync();
|
||||
_pipeClient.Connect();
|
||||
serverWait.GetAwaiter().GetResult();
|
||||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// 1× background consumer-task + 2× MRES (request / done) + cancellation source. Matches the
|
||||
// chunked benchmark's deserialize-side setup cost shape.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
_cts = new CancellationTokenSource();
|
||||
_consumerTask = Task.Run(ConsumerLoop);
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, reads N bytes from pipe, runs Deserialize<T>(bytes), signals
|
||||
// _consumeDone. The Read overlaps with the calling thread's Write through the kernel-pipe; Des happens
|
||||
// sequentially after Read completes (raw byte[] needs the full message to deserialize).
|
||||
private void ConsumerLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var size = _pendingReadSize;
|
||||
var bytes = new byte[size]; // per-iter alloc — counted by BenchmarkLoop.MeasureAllocationTotal
|
||||
var totalRead = 0;
|
||||
while (totalRead < size)
|
||||
{
|
||||
var n = _pipeServer.Read(bytes, totalRead, size - totalRead);
|
||||
if (n == 0) break; // pipe closed / EOF — partial read swallowed
|
||||
totalRead += n;
|
||||
}
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
|
||||
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// 2-task streaming pipeline:
|
||||
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
|
||||
// 2. Calling thread hands off expected size + signals consumer task. Consumer task starts Read loop
|
||||
// on the pipe (BG thread). Calling thread proceeds to Write the bytes — Read and Write overlap
|
||||
// through the kernel-pipe (kernel buffer fills, drains as consumer reads, sender resumes).
|
||||
// 3. Calling thread waits for _consumeDone (consumer task finished Read+Des).
|
||||
//
|
||||
// Note: unlike chunked, raw byte[] cannot do Ser↔Des overlap (Des needs the full bytes before
|
||||
// starting). Only Write↔Read overlaps here. The Des sequence on BG thread is: Read full bytes →
|
||||
// Des the full graph → signal done. This is the architectural difference between raw and chunked.
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
_pendingReadSize = bytes.Length;
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
_pipeClient.Write(bytes, 0, bytes.Length);
|
||||
_pipeClient.Flush();
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Cancel the consumer task → ConsumerLoop exits its Wait via OperationCanceledException.
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
// Symmetric teardown — close client first (writer side), then server.
|
||||
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer engine identifier — replaces the prior <c>Configuration.EngineXxx</c> string constants
|
||||
/// with a type-safe enum. The benchmark-result <c>Engine</c> column uses <see cref="ToDisplay"/> for
|
||||
/// the human-readable form.
|
||||
/// </summary>
|
||||
public enum BenchmarkEngine
|
||||
{
|
||||
AcBinary,
|
||||
MemoryPack,
|
||||
#if !AYCODE_NATIVEAOT
|
||||
MessagePack,
|
||||
#endif
|
||||
SystemTextJson,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// I/O mode identifier — replaces the prior <c>Configuration.IoXxx</c> string constants. Note that
|
||||
/// <see cref="NamedPipe"/> and <see cref="NamedPipeRaw"/> share the display string <c>"NamedPipe"</c>
|
||||
/// (they distinguish chunked-framed vs raw-byte[] semantics, but render identically in the IO column);
|
||||
/// the same applies to <see cref="InMemoryPipe"/> + <see cref="InMemoryRaw"/> (<c>"Pipe(in-mem)"</c>).
|
||||
/// </summary>
|
||||
public enum BenchmarkIoMode
|
||||
{
|
||||
ByteArray,
|
||||
BufWrReuse,
|
||||
BufWrNew,
|
||||
String,
|
||||
NamedPipe,
|
||||
NamedPipeRaw,
|
||||
InMemoryPipe,
|
||||
InMemoryRaw,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch mode identifier — replaces the prior <c>Configuration.ModeXxx</c> string constants.
|
||||
/// Describes how property access / type dispatch happens for a given benchmark row:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SGen"/> — compile-time source generator path (Unsafe.As<T> direct fields, slot-array wrapper lookup).</item>
|
||||
/// <item><see cref="Runtime"/> — reflection / compiled-delegate path.</item>
|
||||
/// <item><see cref="Hybrid"/> — SGen root with non-SGen child types reached via bridge methods (see docs/BINARY/BINARY_SGEN.md).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum BenchmarkDispatchMode
|
||||
{
|
||||
SGen,
|
||||
Runtime,
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-data layer filter — selects which test data cells participate in the run.
|
||||
/// Replaces the prior string-typed <c>layer</c> parameter; CLI/menu callers parse user input via
|
||||
/// <see cref="Enum.TryParse{T}(string, bool, out T)"/> with <c>ignoreCase: true</c>.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="All"/> — no filter; every test data set runs.</item>
|
||||
/// <item><see cref="Core"/>/<see cref="Comprehensive"/>/<see cref="Edge"/> — preset bundles (Comprehensive ⊇ Core, Edge ⊇ Comprehensive).</item>
|
||||
/// <item><see cref="Small"/>/<see cref="Medium"/>/<see cref="Large"/>/<see cref="Repeated"/>/<see cref="Deep"/> — single-cell mini-suites for tight A/B iteration loops.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum BenchmarkLayer
|
||||
{
|
||||
All,
|
||||
Core,
|
||||
Comprehensive,
|
||||
Edge,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Repeated,
|
||||
Deep,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-phase operation filter — selects which sides of the benchmark (Ser, Des, both) run for each
|
||||
/// serializer. Round-trip-only benchmarks (NamedPipe etc.) treat <see cref="Deserialize"/> alone as
|
||||
/// a no-op and only run on <see cref="Serialize"/> or <see cref="All"/>. Replaces the prior string-typed
|
||||
/// <c>mode</c>/<c>opMode</c> parameter.
|
||||
/// </summary>
|
||||
public enum BenchmarkOpMode
|
||||
{
|
||||
All,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializer-set selection — drives the runner's serializer-factory to return one of three
|
||||
/// preset bundles instead of a magic string. Replaces the prior string-typed <c>serializerMode</c>
|
||||
/// parameter.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Standard"/> — full suite minus AsyncPipe (the streaming benchmark is opt-in).</item>
|
||||
/// <item><see cref="FastestByte"/> — focused AcBinary FastMode Byte[] vs MemoryPack Byte[] 1:1 comparison.</item>
|
||||
/// <item><see cref="AsyncPipe"/> — streaming I/O isolation (NamedPipe + in-memory Pipe variants only).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum SerializerSelectionMode
|
||||
{
|
||||
Standard,
|
||||
FastestByte,
|
||||
AsyncPipe,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display-string converters for the benchmark enums. Renders enum values into the column-friendly
|
||||
/// human-readable form used by the per-row console table, the <c>.log</c> file CSV/formatted output,
|
||||
/// and the <c>.LLM</c> markdown table. Centralised here so every output formatter renders identically.
|
||||
/// </summary>
|
||||
public static class BenchmarkEnumExtensions
|
||||
{
|
||||
public static string ToDisplay(this BenchmarkEngine engine) => engine switch
|
||||
{
|
||||
BenchmarkEngine.AcBinary => "AcBinary",
|
||||
BenchmarkEngine.MemoryPack => "MemoryPack",
|
||||
#if !AYCODE_NATIVEAOT
|
||||
BenchmarkEngine.MessagePack => "MessagePack",
|
||||
#endif
|
||||
BenchmarkEngine.SystemTextJson => "System.Text.Json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(engine), engine, null),
|
||||
};
|
||||
|
||||
public static string ToDisplay(this BenchmarkIoMode mode) => mode switch
|
||||
{
|
||||
BenchmarkIoMode.ByteArray => "Byte[]",
|
||||
BenchmarkIoMode.BufWrReuse => "BufWr reuse",
|
||||
BenchmarkIoMode.BufWrNew => "BufWr new",
|
||||
BenchmarkIoMode.String => "String",
|
||||
BenchmarkIoMode.NamedPipe => "NamedPipe",
|
||||
BenchmarkIoMode.NamedPipeRaw => "NamedPipe",
|
||||
BenchmarkIoMode.InMemoryPipe => "Pipe(in-mem)",
|
||||
BenchmarkIoMode.InMemoryRaw => "Pipe(in-mem)",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
|
||||
public static string ToDisplay(this BenchmarkDispatchMode mode) => mode switch
|
||||
{
|
||||
BenchmarkDispatchMode.SGen => "SGen",
|
||||
BenchmarkDispatchMode.Runtime => "Runtime",
|
||||
BenchmarkDispatchMode.Hybrid => "Hybrid",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using MemoryPack;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Per-engine options-formatting + selection helpers shared by all benchmark rows. Centralizes
|
||||
/// the Options-column display string (so the .log / .LLM / console headers stay consistent), the
|
||||
/// MemoryPack <c>WireMode</c>-aligned options selection (so AcBinary FastWire ↔ MemoryPack UTF-16
|
||||
/// comparisons stay apples-to-apples), and the cached <see cref="AttrFlags"/> attribute-flag aggregation.
|
||||
/// </summary>
|
||||
public static class BenchmarkOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregated <see cref="AcBinarySerializableAttribute"/> feature flags across every type tagged with
|
||||
/// the attribute in the loaded assemblies. Cached on first access (single reflection scan at startup).
|
||||
/// Used by <see cref="BuildAcBinary"/> so the per-row Options column shows BOTH the configured
|
||||
/// options-level value AND the effective attribute-level enable flag — a feature flagged off at the
|
||||
/// type level overrides the options regardless of preset, and that asymmetry must surface in the log
|
||||
/// to avoid misreading a "RefHandling=OnlyId" / "Interning=All" line as actually active.
|
||||
/// Aggregation rule: if ALL tagged types have the feature enabled → <c>true</c>; if any tagged type
|
||||
/// disables it → <c>false</c> (a single disabling type suppresses the feature on the type-graph).
|
||||
/// </summary>
|
||||
public static readonly (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) AttrFlags
|
||||
= ScanAttributeFlags();
|
||||
|
||||
private static (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) ScanAttributeFlags()
|
||||
{
|
||||
var attrs = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
|
||||
.Select(t => t.GetCustomAttribute<AcBinarySerializableAttribute>())
|
||||
.Where(a => a != null)
|
||||
.ToList();
|
||||
|
||||
if (attrs.Count == 0) return (false, false, false, false, false);
|
||||
|
||||
return (
|
||||
refHandling: attrs.All(a => a!.EnableRefHandlingFeature),
|
||||
internString: attrs.All(a => a!.EnableInternStringFeature),
|
||||
metadata: attrs.All(a => a!.EnableMetadataFeature),
|
||||
idTracking: attrs.All(a => a!.EnableIdTrackingFeature),
|
||||
propertyFilter: attrs.All(a => a!.EnablePropertyFilterFeature));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common Options-column formatter for every AcBinary serializer benchmark row. Renders the
|
||||
/// configured options-level value AND the effective attribute-level enable flag side-by-side
|
||||
/// (e.g. <c>Interning=All(opt) | False (attr)</c>) so attribute-suppressed features cannot
|
||||
/// silently mislead. Pass any benchmark-specific extras (e.g. <c>", BufferSize=4096B"</c>)
|
||||
/// in <paramref name="extra"/> — they are appended after the common fields.
|
||||
/// </summary>
|
||||
public static string BuildAcBinary(AcBinarySerializerOptions options, string extra = "")
|
||||
{
|
||||
// PropertyFilter: opt-side is "Set"/"None" depending on whether a callback is registered (the callback
|
||||
// itself isn't a meaningful display value); attr-side is the cross-type-aggregated bool (true = every
|
||||
// tagged type has the feature enabled, false = at least one type opted out via
|
||||
// [AcBinarySerializable(enablePropertyFilterFeature: false)] → SGen-emit + Runtime hot-loop both gate).
|
||||
var propFilterOpt = options.PropertyFilter == null ? "None" : "Set";
|
||||
|
||||
return $"WireMode={options.WireMode}, " +
|
||||
$"RefHandling={options.ReferenceHandling}(opt) | {AttrFlags.refHandling} (attr), " +
|
||||
$"Interning={options.UseStringInterning}(opt) | {AttrFlags.internString} (attr), " +
|
||||
$"Metadata={options.UseMetadata}(opt) | {AttrFlags.metadata} (attr), " +
|
||||
$"PropertyFilter={propFilterOpt}(opt) | {AttrFlags.propertyFilter} (attr), " +
|
||||
$"SGen={options.UseGeneratedCode}, " +
|
||||
$"Compression={options.UseCompression}{extra}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns MemoryPack serializer options aligned with the given <paramref name="wireMode"/> for a fair
|
||||
/// apples-to-apples wire-format comparison:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="WireMode.Compact"/> → <see cref="MemoryPackSerializerOptions.Default"/> (UTF-8) — both
|
||||
/// engines encode UTF-8, comparison is purely about header / tier / dispatch overhead.</item>
|
||||
/// <item><see cref="WireMode.Fast"/> → <see cref="MemoryPackSerializerOptions.Utf16"/> (UTF-16 raw memcpy) —
|
||||
/// both engines write UTF-16 raw bytes, so wire-size and CPU comparison reflect the same string-encoding family.</item>
|
||||
/// </list>
|
||||
/// Without this alignment the FastWire vs MemPack-default comparison conflates two unrelated dimensions
|
||||
/// (UTF-16 raw vs UTF-8 encoded) and produces a misleading +40% wire-size delta that is structurally
|
||||
/// the encoding-family difference, NOT an AcBinary-specific overhead.
|
||||
/// </summary>
|
||||
public static MemoryPackSerializerOptions GetMemPack(WireMode wireMode) =>
|
||||
wireMode == WireMode.Fast
|
||||
? MemoryPackSerializerOptions.Utf16
|
||||
: MemoryPackSerializerOptions.Default;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Common contract for every per-engine, per-I/O-mode benchmark row. Each implementation captures
|
||||
/// one (Engine × IoMode × OptionsPreset) combination — e.g. <c>AcBinary Byte[] FastMode SGen</c> —
|
||||
/// and exposes a uniform <c>Serialize</c> / <c>Deserialize</c> hot-path that the benchmark loop
|
||||
/// drives through warmup + adaptive-iter calibration + measurement.
|
||||
///
|
||||
/// <para>The default <see cref="WarmupSerialize"/> + <see cref="WarmupDeserialize"/> methods iterate
|
||||
/// the hot path N times — overrides are only needed when an implementor wants Ser/Des-specific
|
||||
/// warmup state (rare). Round-trip-only benchmarks (NamedPipe etc.) set <see cref="IsRoundTripOnly"/>
|
||||
/// to true so the bench loop skips the Des-phase and routes timing into the RT columns.</para>
|
||||
/// </summary>
|
||||
public interface ISerializerBenchmark
|
||||
{
|
||||
/// <summary>Serializer engine — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkEngine)"/> for the human-readable form.</summary>
|
||||
BenchmarkEngine Engine { get; }
|
||||
/// <summary>I/O mode — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkIoMode)"/> for the human-readable form.</summary>
|
||||
BenchmarkIoMode IoMode { get; }
|
||||
/// <summary>Dispatch mode — typed enum (<see cref="BenchmarkDispatchMode.SGen"/> / <see cref="BenchmarkDispatchMode.Runtime"/> / <see cref="BenchmarkDispatchMode.Hybrid"/>). For AcBinary derived from <c>UseGeneratedCode</c> + child-type SGen coverage; non-AcBinary engines report their own native dispatch model.</summary>
|
||||
BenchmarkDispatchMode DispatchMode { get; }
|
||||
/// <summary>Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression". Stays string because preset names are open-ended (per-instance constructor argument).</summary>
|
||||
string OptionsPreset { get; }
|
||||
/// <summary>
|
||||
/// CLR type of the order graph this benchmark serializes (e.g. <c>typeof(TestOrder_All_False)</c>,
|
||||
/// <c>typeof(TestOrder_All_True)</c>). Per-instance: AcBinary picks variant by options preset
|
||||
/// (caller-side dispatch rule), MemPack / MsgPack always use <c>_All_False</c>.
|
||||
/// Concrete benchmarks return <c>typeof(T)</c> for their generic parameter.
|
||||
/// </summary>
|
||||
Type OrderType { get; }
|
||||
/// <summary>
|
||||
/// Derived display name for the <see cref="OrderType"/>. Default-interface impl reads
|
||||
/// <c>OrderType.Name</c>; concrete classes don't need to override. Surfaced in the SERIALIZER
|
||||
/// OPTIONS section of every output (.log, .LLM, console) — not in the per-row tables — so the
|
||||
/// reader correlates each preset with its TestOrder variant without inflating the result columns.
|
||||
/// </summary>
|
||||
string OrderTypeName => OrderType.Name;
|
||||
/// <summary>Synthesized display name from Engine + IoMode + OptionsPreset.</summary>
|
||||
string Name => $"{Engine.ToDisplay()} ({IoMode.ToDisplay()}, {OptionsPreset})";
|
||||
int SerializedSize { get; }
|
||||
string? OptionsDescription => null;
|
||||
/// <summary>One-time SERIALIZER-side setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants.</summary>
|
||||
long SetupSerializeAllocBytes { get; }
|
||||
/// <summary>One-time DESERIALIZER-side setup allocation cost (e.g., long-lived AsyncPipeReaderInput's ArrayPool rent + ManualResetEventSlim, drain-task scaffolding). Captured in constructor; 0 for byte[] API and any setup-free deserialize path.</summary>
|
||||
long SetupDeserializeAllocBytes { get; }
|
||||
/// <summary>True when Serialize() does a full round-trip (e.g. NamedPipe) and Deserialize() is a no-op.
|
||||
/// Used by the SUMMARY: WINNERS section to skip such cells from "Fastest Serialize" and "Fastest Deserialize"
|
||||
/// rankings (because both metrics are misleading there) — they still participate in "Fastest Round-trip".
|
||||
/// Default false for in-memory IO modes which measure Ser and Des separately.</summary>
|
||||
bool IsRoundTripOnly => false;
|
||||
/// <summary>Warm only the Serialize path. Default body iterates <see cref="Serialize"/> N times.
|
||||
/// Overrides are only needed when the implementor wants Ser-specific warmup-state (e.g. pre-allocate buffers).
|
||||
/// On <see cref="IsRoundTripOnly"/> benchmarks (NamedPipe-style) <see cref="Serialize"/> performs the full RT,
|
||||
/// so this warms the entire round-trip path.</summary>
|
||||
void WarmupSerialize(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++) Serialize();
|
||||
}
|
||||
|
||||
/// <summary>Warm only the Deserialize path. Default body iterates <see cref="Deserialize"/> N times.
|
||||
/// On <see cref="IsRoundTripOnly"/> benchmarks <see cref="Deserialize"/> is a no-op, so the bench loop
|
||||
/// skips the Des-phase entirely for those cells.</summary>
|
||||
void WarmupDeserialize(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++) Deserialize();
|
||||
}
|
||||
|
||||
void Serialize();
|
||||
void Deserialize();
|
||||
/// <summary>Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data.</summary>
|
||||
bool VerifyRoundTrip();
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// MemoryPack benchmark, Byte[] I/O mode. The SOTA baseline AcBinary is compared against in every
|
||||
/// cell. WireMode-aligned options via <see cref="BenchmarkOptions.GetMemPack"/> so Compact ↔ UTF-8
|
||||
/// and FastWire ↔ UTF-16 are apples-to-apples on the string-encoding axis.
|
||||
/// </summary>
|
||||
public sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MemoryPackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
|
||||
|
||||
public MemoryPackBenchmark(T order, WireMode wireMode, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = BenchmarkOptions.GetMemPack(wireMode);
|
||||
_serialized = MemoryPackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => MemoryPackSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = MemoryPackSerializer.Serialize(_order, _options);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<T>(bytes, _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks MemoryPack via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
|
||||
/// Apples-to-apples counterpart to <see cref="AcBinaryBufferWriterBenchmark{T}"/> — MemoryPack's IBufferWriter
|
||||
/// is the path it's designed for.
|
||||
/// </summary>
|
||||
public sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MemoryPackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
private readonly ArrayBufferWriter<byte> _bufferWriter;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrReuse;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
|
||||
|
||||
public MemoryPackBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = BenchmarkOptions.GetMemPack(wireMode);
|
||||
_serialized = MemoryPackSerializer.Serialize(order, _options);
|
||||
|
||||
// Serialize-side setup only — see AcBinaryBufferWriterBenchmark for the full rationale.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
|
||||
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSetup - beforeSetup;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount();
|
||||
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> overload (apples-to-apples with AcBinary's
|
||||
// BufWr Deser path). MemoryPack's ROS overload also single-segment-fast-paths internally.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount();
|
||||
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||||
/// Apples-to-apples counterpart to <see cref="AcBinaryFreshBufferWriterBenchmark{T}"/>.
|
||||
/// </summary>
|
||||
public sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MemoryPackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrNew;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
|
||||
|
||||
public MemoryPackFreshBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = BenchmarkOptions.GetMemPack(wireMode);
|
||||
_serialized = MemoryPackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>();
|
||||
MemoryPackSerializer.Serialize(abw, _order, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> overload (apples-to-apples with AcBinary's
|
||||
// BufWr Deser path). MemoryPack's ROS overload also single-segment-fast-paths internally.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>();
|
||||
MemoryPackSerializer.Serialize(abw, _order, _options);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#if !AYCODE_NATIVEAOT
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// MessagePack benchmark, Byte[] I/O mode. Excluded from NativeAOT build because v3's StandardResolver
|
||||
/// falls back to DynamicGenericResolver for closed-generic types (List<TestOrderItem_All_True> et al.),
|
||||
/// which uses Activator.CreateInstance on formatter types the AOT trimmer drops →
|
||||
/// MissingMethodException at runtime. Available for regular JIT runs (<c>dotnet run</c>) only.
|
||||
/// </summary>
|
||||
public sealed class MessagePackBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MessagePackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MessagePack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MessagePack uses [MessagePackObject] source-generated formatters (StandardResolver)
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription { get; }
|
||||
|
||||
public MessagePackBenchmark(T order, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
|
||||
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
|
||||
|
||||
var isContractless = _options.Resolver is ContractlessStandardResolver;
|
||||
OptionsDescription = $"Mode={( isContractless ? "Contractless" : "ContractBased")}, Compression={_options.Compression}";
|
||||
|
||||
_serialized = MessagePackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MessagePackSerializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = MessagePackSerializer.Serialize(_order, _options);
|
||||
var roundTripped = MessagePackSerializer.Deserialize<T>(bytes, _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Workloads / Scenarios
|
||||
|
||||
Shared workload + scenario types used by **both** the Console runner (custom adaptive measure engine) and the BDN runner (`AcBinaryVsMemPackBenchmark` in the parent folder). Same wire payloads, same options, same round-trip-verify gate → Console and BDN cells are directly comparable.
|
||||
|
||||
## Layout
|
||||
|
||||
### Contract types
|
||||
|
||||
- [`ISerializerBenchmark.cs`](ISerializerBenchmark.cs) — common contract for every (Engine × IoMode × OptionsPreset) row. `Serialize()` / `Deserialize()` hot-path + warmup hooks + `VerifyRoundTrip()` for the pre-warmup correctness gate. Round-trip-only benchmarks (NamedPipe / in-memory Pipe) set `IsRoundTripOnly = true` and let the bench loop skip the Des-phase.
|
||||
- [`BenchmarkEnums.cs`](BenchmarkEnums.cs) — `BenchmarkEngine` / `BenchmarkIoMode` / `BenchmarkDispatchMode` / `BenchmarkLayer` / `BenchmarkOpMode` / `SerializerSelectionMode` + `ToDisplay()` extensions for the column-friendly rendering used by every output formatter.
|
||||
- [`BenchmarkOptions.cs`](BenchmarkOptions.cs) — per-engine options-formatting helpers + the cached `AttrFlags` aggregation (assembly-scan of `[AcBinarySerializable]` feature flags) + `GetMemPack(WireMode)` for the wire-mode-aligned MemoryPack-options selection.
|
||||
- [`RoundTripValidator.cs`](RoundTripValidator.cs) — universal deep-equality oracle via canonical System.Text.Json. Called by every benchmark's `VerifyRoundTrip()` before warmup. AOT-skipped (STJ reflection path incompatible).
|
||||
|
||||
### Concrete benchmarks (12 implementations)
|
||||
|
||||
**AcBinary** (7 variants — different I/O modes):
|
||||
- [`AcBinaryBenchmark.cs`](AcBinaryBenchmark.cs) — `Byte[]` API. Headline AcBinary row.
|
||||
- [`AcBinaryBufferWriterBenchmark.cs`](AcBinaryBufferWriterBenchmark.cs) — pre-allocated, reused `ArrayBufferWriter<byte>`.
|
||||
- [`AcBinaryFreshBufferWriterBenchmark.cs`](AcBinaryFreshBufferWriterBenchmark.cs) — fresh `ArrayBufferWriter` per call (one-shot scenario, 4 KB chunk).
|
||||
- [`AcBinaryNamedPipeBenchmark.cs`](AcBinaryNamedPipeBenchmark.cs) — chunked-framed `AsyncPipe` over kernel NamedPipe (long-lived, multi-message, 2-task pipeline).
|
||||
- [`AcBinaryNamedPipeRawByteArrayBenchmark.cs`](AcBinaryNamedPipeRawByteArrayBenchmark.cs) — raw `byte[]` over kernel NamedPipe (no chunk-framing, Read+Des sequential after Read completes).
|
||||
- [`AcBinaryInMemoryPipeBenchmark.cs`](AcBinaryInMemoryPipeBenchmark.cs) — chunked-framed `AsyncPipe` over in-memory `System.IO.Pipelines.Pipe` (zero kernel involvement, isolates streaming-framework CPU cost from kernel-pipe transport overhead).
|
||||
- [`AcBinaryInMemoryRawByteArrayBenchmark.cs`](AcBinaryInMemoryRawByteArrayBenchmark.cs) — raw `byte[]` over in-memory cross-thread handoff (no transport at all, completes the 2×2 [chunked|raw] × [kernel|memory] matrix).
|
||||
|
||||
**MemoryPack** (3 variants — apples-to-apples with the AcBinary I/O modes):
|
||||
- [`MemoryPackBenchmark.cs`](MemoryPackBenchmark.cs) — `Byte[]` API. SOTA baseline.
|
||||
- [`MemoryPackBufferWriterBenchmark.cs`](MemoryPackBufferWriterBenchmark.cs) — reused `ArrayBufferWriter`.
|
||||
- [`MemoryPackFreshBufferWriterBenchmark.cs`](MemoryPackFreshBufferWriterBenchmark.cs) — fresh `ArrayBufferWriter` per call.
|
||||
|
||||
**Other** (reference comparison, typically disabled in active suite):
|
||||
- [`MessagePackBenchmark.cs`](MessagePackBenchmark.cs) — JIT-only (AOT-incompatible — v3 StandardResolver falls back to `Activator.CreateInstance` on trimmed closed-generic types).
|
||||
- [`SystemTextJsonBenchmark.cs`](SystemTextJsonBenchmark.cs) — String I/O mode, reflection-based metadata. Far behind binary serializers on µs/op; useful as a JSON baseline when activated.
|
||||
|
||||
## Convention
|
||||
|
||||
Every concrete benchmark:
|
||||
|
||||
1. Stores the test data graph + serializer options in its ctor and pre-computes a `_serialized` byte array for `SerializedSize` reporting.
|
||||
2. Implements `Serialize()` / `Deserialize()` as `[MethodImpl(NoInlining)]` hot-paths — the bench loop drives these directly through warmup + adaptive-iter calibration + measurement.
|
||||
3. Implements `VerifyRoundTrip()` by calling `RoundTripValidator.DeepEqualsViaJson(original, roundTripped)` on the result of a single Ser+Des pass.
|
||||
4. Round-trip-only variants (NamedPipe / in-memory Pipe) override `IsRoundTripOnly => true`, route the full Ser+wire+Des roundtrip through `Serialize()`, and leave `Deserialize()` as a no-op.
|
||||
|
||||
The runner (Console `BenchmarkLoop` or BDN `AcBinaryVsMemPackBenchmark`) creates the appropriate concrete via factory helpers and drives the contract — no scenario-specific knowledge in the runner.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip correctness validator — serializes both sides to canonical System.Text.Json form
|
||||
/// and compares the resulting strings. Works for any object graph without a custom comparer (slower
|
||||
/// than property-by-property but universal). Used by every benchmark's <c>VerifyRoundTrip()</c>
|
||||
/// implementation as the deep-equality oracle before warmup begins.
|
||||
/// </summary>
|
||||
public static class RoundTripValidator
|
||||
{
|
||||
#if !AYCODE_NATIVEAOT
|
||||
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip equality check via canonical System.Text.Json. Returns true if both sides serialize
|
||||
/// to identical JSON strings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AOT publish skip: <c>System.Text.Json</c>'s reflection path uses runtime closed-generic instantiation
|
||||
/// (<c>JsonPropertyInfo<TestStatus></c> et al.) that the trimmer drops, causing
|
||||
/// <c>NotSupportedException: missing native code or metadata</c>. The validation is JIT-only — the actual
|
||||
/// benchmark Serialize/Deserialize loops don't touch this path. Under AOT we return <c>true</c> so all
|
||||
/// <c>VerifyRoundTrip()</c> calls pass without running the cross-format validation.
|
||||
/// </remarks>
|
||||
public static bool DeepEqualsViaJson(object? a, object? b)
|
||||
{
|
||||
#if AYCODE_NATIVEAOT
|
||||
// Skip cross-format validation under AOT — STJ reflection path is incompatible. The roundtrip
|
||||
// itself still runs (caller-side Serialize+Deserialize), just the JSON-canonical compare is bypassed.
|
||||
return true;
|
||||
#else
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts);
|
||||
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
|
||||
|
||||
return jsonA == jsonB;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json benchmark, String I/O mode. Reference comparison — uses reflection-based metadata
|
||||
/// (no source-generator opt-in here). Typically NOT in the active suite (commented out in the
|
||||
/// caller-side <c>CreateSerializers</c>); ranks far behind binary serializers on µs/op but provides
|
||||
/// a familiar JSON baseline when needed.
|
||||
/// </summary>
|
||||
public sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private readonly string _serialized;
|
||||
private readonly byte[] _serializedUtf8;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.SystemTextJson;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.String;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.Runtime; // System.Text.Json default uses reflection-based metadata (no source generator opt-in here)
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serializedUtf8.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
|
||||
public SystemTextJsonBenchmark(T order, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
_serialized = JsonSerializer.Serialize(order, _options);
|
||||
// Encoding.UTF8.GetBytes(string) does NOT prepend the BOM (the preamble is only emitted by
|
||||
// GetPreamble() / stream-level writes), so this produces identical bytes to the prior
|
||||
// `new UTF8Encoding(false).GetBytes(_serialized)` call. Size-reporting only.
|
||||
_serializedUtf8 = Encoding.UTF8.GetBytes(_serialized);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => JsonSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => JsonSerializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_order, _options);
|
||||
var roundTripped = JsonSerializer.Deserialize<T>(json, _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,53 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Benchmark\AyCode.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MemoryPack" Version="1.21.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MemoryPack" Version="1.21.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..\AyCode.Core.targets" />
|
||||
|
||||
<!-- AOT-mode is publish-time only.
|
||||
Why conditional on $(_IsPublishing): with .NET 8+, an unconditional <PublishAot>true</PublishAot>
|
||||
forces the SDK to auto-set <IsDynamicCodeSupported>false</IsDynamicCodeSupported> as a runtime
|
||||
host config option — meaning even regular `dotnet build` / `dotnet run` outputs report
|
||||
RuntimeFeature.IsDynamicCodeSupported == false at runtime. That makes AcSerializerCommon's
|
||||
Runtime path take the reflection fallback (ctor.Invoke / PropertyInfo.GetValue) instead of
|
||||
Expression.Compile during JIT testing — Release benchmark numbers measure reflection, not
|
||||
compiled expressions. Restricting PublishAot to actual publish keeps JIT semantics for
|
||||
`dotnet build` / `dotnet run` while preserving full AOT analysis on `dotnet publish`.
|
||||
|
||||
AYCODE_NATIVEAOT define moved here too — it's the publish-time #if symbol that gates out
|
||||
MessagePack benchmark + STJ-based DeepEqualsViaJson validation in Program.cs (both
|
||||
incompatible with AOT trim/runtime constraints). Same conditioning ensures the symbol is
|
||||
defined exactly when PublishAot is in effect. -->
|
||||
<PropertyGroup Condition="'$(_IsPublishing)' == 'true'">
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<DefineConstants>$(DefineConstants);AYCODE_NATIVEAOT</DefineConstants>
|
||||
|
||||
<!-- DAMs propagation chain landed across the public Deserialize<T>/Serialize<T> entry points down to
|
||||
AcSerializerCommon factory methods. Remaining trim warnings concentrate on:
|
||||
(a) serialize-side polymorphism via obj.GetType() — fundamental trimmer blind spot
|
||||
(b) internal Type-flow through serialize helpers (ScanValueGenerated, WritePropertyOrSkip)
|
||||
(c) external dependencies (MemoryPack/MessagePack/AutoMapper/MongoDB/STJ) — out of scope
|
||||
Suppress for now so builds succeed; revisit if AOT runtime issues surface beyond ctor metadata. -->
|
||||
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,888 @@
|
|||
using AyCode.Core.Benchmarks.Reporting;
|
||||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark execution: end-to-end orchestration (<see cref="RunBenchmark"/>), per-cell loop
|
||||
/// (<see cref="RunBenchmarksForTestData"/>), serializer factory (<see cref="CreateSerializers"/>),
|
||||
/// and the timing / calibration / allocation helpers. Pure benchmark-execution infrastructure —
|
||||
/// no display formatting (that lives in <c>Output</c>) and no UX-flow (that lives in <c>Program</c>
|
||||
/// + <c>Menu</c>).
|
||||
/// </summary>
|
||||
internal static class BenchmarkLoop
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the benchmark suite end-to-end for the given configuration: pre-warmup → per-cell warmup
|
||||
/// + measurement → grouped results print → save to disk. Used by both the CLI and interactive
|
||||
/// menu paths; the interactive loop calls this repeatedly without restarting the process.
|
||||
/// </summary>
|
||||
internal static void RunBenchmark(BenchmarkLayer layer, BenchmarkOpMode opMode, SerializerSelectionMode serializerMode)
|
||||
{
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
// Stabilization: pin the entire benchmark process to a single logical CPU and bump priority
|
||||
// class. Single-core affinity stops Windows from migrating the bench thread between cores
|
||||
// mid-sample (a migration evicts L1/L2 caches and corrupts a measurement); High priority
|
||||
// reduces preemption by background tasks (Defender scans, indexer, etc.) that otherwise
|
||||
// randomly inflate samples by 5-15%.
|
||||
// Try/finally guarantees the original state is restored even if a benchmark throws — leaving
|
||||
// a developer machine pinned to one core after a crashed run is a real foot-gun.
|
||||
// Skipped on Debug single-sample mode (Configuration.BenchmarkSamples <= 1) where stabilization is moot.
|
||||
var process = Process.GetCurrentProcess();
|
||||
var origAffinity = (IntPtr)0;
|
||||
var origPriority = ProcessPriorityClass.Normal;
|
||||
var stabilizationApplied = false;
|
||||
|
||||
// ProcessorAffinity is only supported on Windows + Linux (CA1416). macOS would throw at
|
||||
// runtime; skip the affinity step there but still raise priority class (which IS supported
|
||||
// on macOS, just less effective for stabilization than affinity pinning).
|
||||
if (Configuration.BenchmarkSamples > 1 && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
|
||||
{
|
||||
try
|
||||
{
|
||||
origAffinity = process.ProcessorAffinity;
|
||||
origPriority = process.PriorityClass;
|
||||
// Pin to CPU 0 (mask = 1). Choosing CPU 0 is arbitrary; what matters is "exactly one
|
||||
// core, consistently" — not which one. If CPU 0 is heavily contended on the host
|
||||
// (e.g. dedicated to system-wide IRQs on some Windows configs), the user can tweak
|
||||
// the mask here. The benchmark is single-threaded for the in-memory rows so single
|
||||
// core is sufficient; round-trip-only NamedPipe rows have a server-drain thread
|
||||
// that will share the core (acceptable — the bench measures end-to-end RT anyway).
|
||||
process.ProcessorAffinity = (IntPtr)1;
|
||||
process.PriorityClass = ProcessPriorityClass.High;
|
||||
stabilizationApplied = true;
|
||||
System.Console.WriteLine($"Stabilization: pinned to CPU 0 (affinity=0x1), priority=High.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Affinity/priority changes may fail on locked-down hosts (group policies, containers
|
||||
// without CAP_SYS_NICE on Linux, etc.). Surface and continue — the benchmark still
|
||||
// works, just with the platform default scheduling.
|
||||
System.Console.WriteLine($"Stabilization SKIPPED: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var allResults = new List<BenchmarkResult>();
|
||||
var allTestDataSets = BuildMultiVariantTestDataSets();
|
||||
var testDataSets = FilterByLayer(allTestDataSets, layer);
|
||||
|
||||
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Charset: {Configuration.GetCurrentCharsetName()} | Iterations: per-cell adaptive (~{Configuration.TargetSampleMs} ms target) | Warmup: {Configuration.WarmupIterations} per phase (Ser/Des isolated) | Samples: {Configuration.BenchmarkSamples} (median) + pilot discard");
|
||||
System.Console.WriteLine($"Build: {Configuration.BuildConfiguration} | .NET: {Environment.Version} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
// Global JIT pre-warmup — touches every (testdata × serializer) code path BEFORE any timing happens.
|
||||
// Without this, the FIRST test data measured carries JIT-tier-promotion latency: the per-cell warmup
|
||||
// alone doesn't ensure that every Serialize<T>/IBufferWriter overload is fully Tier 1 by the time we
|
||||
// start measuring. Symptom: first cell's BufferWriter variants run ~2x slower than the SAME variants
|
||||
// on later cells (e.g. Small BufWr reuse 9ms vs Medium BufWr reuse 4ms — even though Medium is bigger).
|
||||
// Pre-warmup runs every overload at least once with each data shape so .NET 9's tiered JIT promotes
|
||||
// them all in the background; the per-cell warmup that follows then locks in cache + branch state.
|
||||
if (Configuration.BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
|
||||
{
|
||||
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var preSerializers = CreateSerializers(testData, serializerMode);
|
||||
try
|
||||
{
|
||||
foreach (var s in preSerializers)
|
||||
{
|
||||
// Light warmup just to trigger Tier 0 → Tier 1 promotion. Phase-isolated:
|
||||
// Ser path first, then Des path — same pattern as the per-cell warmup in
|
||||
// RunBenchmarksForTestData (which still runs afterwards for cache/BTB warming).
|
||||
s.WarmupSerialize(2000);
|
||||
s.WarmupDeserialize(2000);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources).
|
||||
foreach (var s in preSerializers) (s as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Let background tiered-JIT compilation drain before we begin measuring.
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
|
||||
}
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
|
||||
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
|
||||
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
|
||||
|
||||
var results = RunBenchmarksForTestData(testData, opMode, serializerMode);
|
||||
allResults.AddRange(results);
|
||||
}
|
||||
|
||||
// Build the reporting context (resolves path via walk-up to .sln, snapshots run-config).
|
||||
var ctx = new ReportingContext(
|
||||
SourceTag: "Console",
|
||||
ResultsDirectory: ReportingContext.ResolveResultsDirectory(),
|
||||
BuildConfiguration: Configuration.BuildConfiguration,
|
||||
Utf8NoBom: Configuration.Utf8NoBom,
|
||||
CharsetName: Configuration.GetCurrentCharsetName(),
|
||||
WarmupIterations: Configuration.WarmupIterations,
|
||||
BenchmarkSamples: Configuration.BenchmarkSamples,
|
||||
TargetSampleMs: Configuration.TargetSampleMs,
|
||||
UnstableCVThreshold: Configuration.UnstableCVThreshold,
|
||||
MicroOptCVThreshold: Configuration.MicroOptCVThreshold);
|
||||
|
||||
// Print grouped results
|
||||
BenchmarkReportWriter.PrintGroupedResults(allResults, testDataSets);
|
||||
|
||||
// Save results to file (.log + .LLM + .output)
|
||||
BenchmarkReportWriter.SaveAll(ctx, allResults, testDataSets);
|
||||
|
||||
System.Console.WriteLine("\n✓ Benchmark complete!");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore process state — affinity/priority changes are process-wide and persist across
|
||||
// interactive-mode iterations of the menu. Without restore, the second menu run would
|
||||
// already be on CPU-0 + High priority before its own try-block applied them, masking
|
||||
// any stabilization-disabled comparison.
|
||||
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
|
||||
{
|
||||
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
|
||||
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, BenchmarkOpMode mode, SerializerSelectionMode serializerMode)
|
||||
{
|
||||
var results = new List<BenchmarkResult>();
|
||||
var serializers = CreateSerializers(testData, serializerMode);
|
||||
|
||||
// Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure.
|
||||
System.Console.WriteLine("Verifying round-trip correctness...");
|
||||
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
if (!serializer.VerifyRoundTrip())
|
||||
{
|
||||
System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}");
|
||||
System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting.");
|
||||
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
System.Console.WriteLine("✓ All serializers passed round-trip verification.");
|
||||
|
||||
// Per-serializer, PER-PHASE (warmup → calibrate → measurement) cycle: each serializer's Ser-path and
|
||||
// Des-path get COMPLETELY ISOLATED warmup→measure rounds, with a GC.Collect at every phase boundary.
|
||||
//
|
||||
// Why phase-isolation: a combined warmup (Ser+Des interleaved) leaves the CPU I-cache + branch-predictor
|
||||
// in a "compromise state" — neither Ser nor Des code-set dominates. The first phase to measure pays a
|
||||
// cache-miss penalty as its code-set displaces the leftover-warmup-state. Isolated warmup→measure pairs
|
||||
// keep the I-cache HOT for ONLY the measured path, both in the warmup (priming) and the measurement
|
||||
// (steady-state). Branch-predictor history also stays clean per path.
|
||||
//
|
||||
// GC.Collect at every boundary: removes residual allocation pressure from the previous phase (write-buffer
|
||||
// pool churn from Ser, deserialized object graph from Des) so the next phase starts with a quiescent
|
||||
// heap — GC tier-promotion timing during measurement is then driven only by THAT phase's allocations.
|
||||
//
|
||||
// Configuration.JitSleep per-phase: tiered JIT background promotion drain after each warmup (mode-aware: 0 ms in AOT).
|
||||
// Each phase's freshly-promoted methods settle before its timing starts.
|
||||
System.Console.WriteLine($"Running benchmarks (target ~{Configuration.TargetSampleMs} ms/sample × {Configuration.BenchmarkSamples} samples median, phase-isolated warmup/measure per Ser/Des)...\n");
|
||||
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
var result = new BenchmarkResult
|
||||
{
|
||||
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
|
||||
Engine = serializer.Engine,
|
||||
IoMode = serializer.IoMode,
|
||||
DispatchMode = serializer.DispatchMode,
|
||||
OptionsPreset = serializer.OptionsPreset,
|
||||
OrderTypeName = serializer.OrderTypeName,
|
||||
OptionsDescription = serializer.OptionsDescription,
|
||||
SerializedSize = serializer.SerializedSize,
|
||||
SetupSerializeAllocBytes = serializer.SetupSerializeAllocBytes,
|
||||
SetupDeserializeAllocBytes = serializer.SetupDeserializeAllocBytes,
|
||||
IsRoundTripOnly = serializer.IsRoundTripOnly
|
||||
};
|
||||
|
||||
// Group label for in-place \r progress. Identifies (cell × serializer) so a stuck benchmark
|
||||
// is visibly stuck on a specific row at a specific %% rather than silently hanging.
|
||||
var groupLabel = $"{result.SerializerName}";
|
||||
|
||||
if (serializer.IsRoundTripOnly)
|
||||
{
|
||||
// Round-trip-only benchmarks (NamedPipe etc.): single phase — Serialize() performs the full RT,
|
||||
// Deserialize() is a no-op. We use the Ser-phase entry-points (WarmupSerialize) to warm the
|
||||
// entire round-trip path, then record into the RT result columns.
|
||||
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Serialize)
|
||||
{
|
||||
ForceGcCollect();
|
||||
serializer.WarmupSerialize(Configuration.WarmupIterations);
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
|
||||
var rtIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
|
||||
var (rtMed, rtMin, rtMax, rtStd) = RunTimed(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT timing]");
|
||||
result.RoundTripTimeMs = rtMed;
|
||||
result.RoundTripTimeMinMs = rtMin;
|
||||
result.RoundTripTimeMaxMs = rtMax;
|
||||
result.RoundTripTimeStdDevMs = rtStd;
|
||||
result.RoundTripIterations = rtIter;
|
||||
// Process-wide allocation measurement: server-drain-thread allocations (server-side new byte[len])
|
||||
// also show up — otherwise current-thread alloc would only count the client side and look ~halved.
|
||||
result.RoundTripAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT alloc]", processWide: true);
|
||||
}
|
||||
// mode == BenchmarkOpMode.Deserialize alone is meaningless for a round-trip-only benchmark; skip silently.
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Ser phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
|
||||
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Serialize)
|
||||
{
|
||||
ForceGcCollect();
|
||||
serializer.WarmupSerialize(Configuration.WarmupIterations);
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
|
||||
var serIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
|
||||
var (serMed, serMin, serMax, serStd) = RunTimed(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser timing]");
|
||||
result.SerializeTimeMs = serMed;
|
||||
result.SerializeTimeMinMs = serMin;
|
||||
result.SerializeTimeMaxMs = serMax;
|
||||
result.SerializeTimeStdDevMs = serStd;
|
||||
result.SerializeIterations = serIter;
|
||||
// Dedicated alloc-only sample (separate from timing samples; keeps timing pure)
|
||||
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser alloc]");
|
||||
}
|
||||
|
||||
// ── Des phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
|
||||
// The GC.Collect here is critical: it discards the Ser-phase's write-buffer pool churn so the
|
||||
// Des-phase's allocation measurement reflects ONLY Des-side allocations (deserialized object graph).
|
||||
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Deserialize)
|
||||
{
|
||||
ForceGcCollect();
|
||||
serializer.WarmupDeserialize(Configuration.WarmupIterations);
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
|
||||
var desIter = CalibrateIterations(() => serializer.Deserialize(), Configuration.TargetSampleMs);
|
||||
var (desMed, desMin, desMax, desStd) = RunTimed(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des timing]");
|
||||
result.DeserializeTimeMs = desMed;
|
||||
result.DeserializeTimeMinMs = desMin;
|
||||
result.DeserializeTimeMaxMs = desMax;
|
||||
result.DeserializeTimeStdDevMs = desStd;
|
||||
result.DeserializeIterations = desIter;
|
||||
result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des alloc]");
|
||||
}
|
||||
|
||||
// Compose RT from Ser+Des. Because Ser and Des may have DIFFERENT iter counts post-calibration,
|
||||
// batch-time addition would be misleading. Instead: compute per-op µs (iter-independent),
|
||||
// then synthesize RoundTripTimeMs against RoundTripIterations = max(serIter, desIter) so that
|
||||
// RoundTripTimeMs / RoundTripIterations * 1000 == Output.SerPerOp + Output.DesPerOp.
|
||||
var serPerOp = BenchmarkReportWriter.ToPerOpMicros(result.SerializeTimeMs, result.SerializeIterations);
|
||||
var desPerOp = BenchmarkReportWriter.ToPerOpMicros(result.DeserializeTimeMs, result.DeserializeIterations);
|
||||
var rtPerOp = serPerOp + desPerOp;
|
||||
result.RoundTripIterations = Math.Max(result.SerializeIterations, result.DeserializeIterations);
|
||||
result.RoundTripTimeMs = rtPerOp / 1000.0 * result.RoundTripIterations;
|
||||
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
BenchmarkReportWriter.PrintResult(result);
|
||||
}
|
||||
|
||||
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources that must be released
|
||||
// before the next test data builds new ones — otherwise pipes / handles leak across test cells).
|
||||
foreach (var s in serializers) (s as IDisposable)?.Dispose();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 multi-variant test-data builder. Constructs each cell in both the _All_False and
|
||||
/// _All_True families, then cross-registers _All_True on the _All_False primaries so the
|
||||
/// CreateSerializers downstream can pick the matching variant per AcBinary options preset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Memory cost: ~600 KB across 5 cells (Large dominates at ~340 KB for both variants). The two
|
||||
/// families are built independently — same data values + same numeric sequence (per-family
|
||||
/// _idCounter reset). MemPack/MsgPack benchmarks consume the _All_True variant canonically;
|
||||
/// AcBinary's variant is preset-dependent (see CreateSerializers).
|
||||
/// </remarks>
|
||||
private static List<TestDataSet> BuildMultiVariantTestDataSets()
|
||||
{
|
||||
var allFalse = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var allTrue = BenchmarkTestDataProvider.CreateTestDataSets();
|
||||
|
||||
// Zip by ordinal — both providers emit the same 5 cells in the same order
|
||||
// (Small / Medium / Large / Repeated / Deep), confirmed by their identical
|
||||
// CreateTestDataSets call sequence on the generic base.
|
||||
for (var i = 0; i < allFalse.Count; i++)
|
||||
{
|
||||
var falseDs = (TestDataSet<TestOrder_All_False>)allFalse[i];
|
||||
var trueDs = (TestDataSet<TestOrder_All_True>)allTrue[i];
|
||||
falseDs.RegisterVariant(trueDs.Order);
|
||||
}
|
||||
return allFalse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 variant dispatch rule for AcBinary: a preset uses <c>TestOrder_All_False</c> iff every
|
||||
/// AcBinary "feature flag" is off (no string interning, no reference handling, no metadata, no
|
||||
/// property filter). Any "true"-flagged feature promotes the benchmark to <c>TestOrder_All_True</c>
|
||||
/// — the richer graph + opt-out attribute model exercises the feature's deduplication / dispatch
|
||||
/// path on real shared-reference content. WireMode, SGen mode, and Compression are encoding-axis
|
||||
/// options and intentionally NOT part of this decision (they don't change which graph shape is
|
||||
/// meaningful to feed).
|
||||
/// </summary>
|
||||
private static bool UsesAllFalseVariant(AcBinarySerializerOptions options) =>
|
||||
options.UseStringInterning == StringInterningMode.None &&
|
||||
options.ReferenceHandling == ReferenceHandlingMode.None &&
|
||||
!options.UseMetadata &&
|
||||
options.PropertyFilter == null;
|
||||
|
||||
// Per-class factory helpers — each returns ISerializerBenchmark closed over the variant T
|
||||
// selected by UsesAllFalseVariant(options). Compile-time T at the new T() call site preserves
|
||||
// SGen apples-to-apples (no runtime reflection, no type erasure across the JIT boundary).
|
||||
private static ISerializerBenchmark MakeAcBinary(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryBufferWriter(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryBufferWriterBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryBufferWriterBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryFreshBufferWriter(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryFreshBufferWriterBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryFreshBufferWriterBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryNamedPipe(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryNamedPipeBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryNamedPipeBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryNamedPipeRaw(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryNamedPipeRawByteArrayBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryNamedPipeRawByteArrayBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryInMemoryPipe(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryInMemoryPipeBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryInMemoryPipeBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryInMemoryRaw(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryInMemoryRawByteArrayBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryInMemoryRawByteArrayBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData, SerializerSelectionMode serializerMode)
|
||||
{
|
||||
// Phase 2 variant dispatch (refined): AcBinary picks variant per UsesAllFalseVariant(options).
|
||||
// MemPack / MsgPack canonically use _All_False (no AcBinary opt-in/opt-out axis — both
|
||||
// produce identical MemPack/MsgPack wire on either variant since their contract is family-
|
||||
// agnostic). `orderFalse` is the cell primary; `orderTrue` is fetched on-demand by the AcBinary
|
||||
// factory helpers when an options preset has a "true" flag.
|
||||
var orderFalse = testData.GetOrder<TestOrder_All_False>();
|
||||
|
||||
// FastestByte mode — focused 1:1 comparison on the "fastest Byte[]" path.
|
||||
// TWO benchmarks: AcBinary FastMode Byte[] (Compact UTF-8) + MemoryPack Byte[].
|
||||
// - Compact: smallest wire, UTF-8 encode/decode CPU cost vs MemPack head-to-head.
|
||||
// Tight optimization-iteration loop: ~30-45 sec vs full 2-3 min.
|
||||
//
|
||||
// FastWire row (UTF-16 raw memcpy) commented out for the current optimization sprint —
|
||||
// we are tuning Compact mode against MemPack directly; FastWire was used as a noise-floor
|
||||
// reference earlier. Re-enable when revisiting Fast wire-mode performance.
|
||||
if (serializerMode == SerializerSelectionMode.FastestByte)
|
||||
{
|
||||
var fastestByteOptions = AcBinarySerializerOptions.FastMode;
|
||||
fastestByteOptions.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
MakeAcBinary(testData, fastestByteOptions, "FastMode"),
|
||||
//MakeAcBinary(testData, fastWireOptions, "FastMode (FastWire)"),
|
||||
// MemPack uses _All_False (the AcBinary opt-in/opt-out axis doesn't apply — MemoryPackable
|
||||
// serialises identical bytes either way; _All_False matches the orderFalse variant the test
|
||||
// data factory already built, no extra graph allocation needed).
|
||||
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
};
|
||||
}
|
||||
|
||||
// AsyncPipe-only mode — return ONLY the AsyncPipe streaming benchmark (no other serializer).
|
||||
// Streaming I/O has long-lived pipe setup + kernel-buffer overhead that, when interleaved with
|
||||
// the standard byte-array / IBufferWriter measurements, masks the steady-state numbers. Run it
|
||||
// in isolation so the timing numbers reflect ONLY the streaming path.
|
||||
if (serializerMode == SerializerSelectionMode.AsyncPipe)
|
||||
{
|
||||
// NamedPipe — pipe-aligned chunk size for the long-lived IPC scenario. The chunkSize here
|
||||
// drives the AsyncPipeWriterOutput's chunk-on-wire size (header + data, page-aligned thanks to
|
||||
// the AcquireChunk fix) AND the kernel pipe buffer size (inBufferSize/outBufferSize on the
|
||||
// NamedPipeServerStream ctor). Same value across both layers = one WriteFile(chunkSize) syscall
|
||||
// fits blocking-free in one kernel pipe-buffer slot. Single source of truth for both app-level
|
||||
// wire chunk AND kernel transfer unit; change ONLY this line when tuning.
|
||||
var binaryFastModePipeChunkOnly = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModePipeChunkOnly.BufferWriterChunkSize = Configuration.PipeChunkSize;
|
||||
binaryFastModePipeChunkOnly.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
// Chunked-framed AsyncPipe: SerializeChunkedFramed + AsyncPipeReaderInput.DrainFromAsync.
|
||||
// Measures the FULL streaming-I/O stack — wire framing + drain task + sliding-window buffer +
|
||||
// MRES wait-on-byte-shortage — over a kernel NamedPipe.
|
||||
MakeAcBinaryNamedPipe(testData, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
|
||||
// Raw byte[] over NamedPipe (sync receive, no chunk-framing). Same kernel-pipe transport,
|
||||
// same inBufferSize, but: serialize → byte[] → Stream.Write → Stream.Read → Deserialize<T>(byte[]).
|
||||
// No drain task, no AsyncPipeReaderInput, no [201][UINT16][data]…[202] framing. Side-by-side with
|
||||
// the chunked-row above this isolates AsyncPipe-framework-overhead (Δ vs raw) from
|
||||
// kernel-transport-overhead (raw vs in-process Byte[]).
|
||||
MakeAcBinaryNamedPipeRaw(testData, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"),
|
||||
// Chunked-framed AsyncPipe over an IN-MEMORY System.IO.Pipelines.Pipe (NO NamedPipe, NO kernel).
|
||||
// Same chunked-streaming code path (SerializeChunkedFramed → AsyncPipeReaderInput) but with the
|
||||
// kernel-pipe replaced by a managed-only Pipe. Eliminates per-chunk syscall overhead (~30 µs/chunk
|
||||
// on NamedPipe → ~1-2 µs/chunk on in-memory Pipe). Side-by-side with the NamedPipe row above this
|
||||
// isolates pure CPU cost of the chunked-streaming framework (vs kernel-pipe transport cost) — the
|
||||
// in-memory Pipe row should be much closer to the raw-byte[] row, validating that NamedPipe loopback
|
||||
// is the worst-case benchmark scenario for chunked-streaming and not representative of real network
|
||||
// / file / cross-thread Pipe scenarios.
|
||||
MakeAcBinaryInMemoryPipe(testData, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
|
||||
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport at all). Apples-to-apples
|
||||
// baseline for the in-memory chunked row above: same in-memory transport (zero kernel), but raw
|
||||
// byte[] vs chunked-streaming wire format. Completes the 2x2 matrix [chunked,raw] × [kernel,memory].
|
||||
MakeAcBinaryInMemoryRaw(testData, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"),
|
||||
};
|
||||
}
|
||||
|
||||
// Standard mode — all serializers EXCEPT AsyncPipe (the streaming benchmark is opt-in via the
|
||||
// AsyncPipe menu / CLI mode, never bundled with the steady-state suite).
|
||||
|
||||
var binaryNoInternOption = AcBinarySerializerOptions.Default;
|
||||
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
|
||||
binaryNoInternOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
|
||||
binaryDefaultNoSgenOption.UseGeneratedCode = false;
|
||||
binaryDefaultNoSgenOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeNoSgenOption.UseGeneratedCode = false;
|
||||
binaryFastModeNoSgenOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var binaryFastModeOption = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
// BufWr new — 4 KB chunk size for the FRESH ArrayBufferWriter scenario. The chunkSize here drives
|
||||
// the serializer's GetSpan(N) request → the ArrayBufferWriter's internal allocation per call.
|
||||
// Small chunk = small per-call allocation, optimum for one-shot serialization where each iteration
|
||||
// allocates a fresh ABW. Independent of the AsyncPipe profile (different mechanism: alloc overhead
|
||||
// vs syscall count).
|
||||
var binaryFastModeBufWrChunk = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeBufWrChunk.BufferWriterChunkSize = Configuration.PipeChunkSize;
|
||||
binaryFastModeBufWrChunk.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
// In-memory Pipe variant — same 4 KB chunkSize as the AsyncPipe mode, no kernel-pipe alignment
|
||||
// concern (managed slabs are not page-aligned anyway). Drives SerializeChunkedFramed via the in-memory
|
||||
// System.IO.Pipelines.Pipe (zero-copy slab handoff between producer and drain task).
|
||||
var binaryFastModePipeChunkInMem = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModePipeChunkInMem.BufferWriterChunkSize = Configuration.PipeChunkSize;
|
||||
binaryFastModePipeChunkInMem.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var defaultOptions = AcBinarySerializerOptions.Default;
|
||||
defaultOptions.UseStringInterning = StringInterningMode.None;
|
||||
defaultOptions.ReferenceHandling = ReferenceHandlingMode.OnlyId;
|
||||
defaultOptions.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
// ============================================================
|
||||
// AcBinary — Byte[] API (uncomment to compare option presets side-by-side)
|
||||
// ============================================================
|
||||
// Fastest Byte[] — SGen path (UseGeneratedCode=true, default).
|
||||
MakeAcBinary(testData, binaryFastModeOption, "FastMode"),
|
||||
// Fastest Byte[] — Runtime path (UseGeneratedCode=false). Same wire/options, no source-generated dispatch.
|
||||
// Always paired with the SGen variant so every layer can compare the SGen speed-up apples-to-apples.
|
||||
// NativeAOT-safe: AcSerializerCommon.Create*Getter/Setter falls back to reflection-based delegates
|
||||
// when RuntimeFeature.IsDynamicCodeSupported is false (slower but works under AOT publish).
|
||||
MakeAcBinary(testData, binaryFastModeNoSgenOption, "FastMode"),
|
||||
// Default preset Byte[] — RefHandling=OnlyId (deduplicates IId-shared references on the wire) +
|
||||
// UseStringInterning=All (deduplicates repeated strings). Showcases the Default preset's wire-size
|
||||
// and CPU trade-off vs FastMode on the ~20% IId-ref / repeated-string test data.
|
||||
|
||||
// Default preset (ReferenceHandling=OnlyId + StringInterning) → _All_True graph.
|
||||
// Phase 2 variant-dispatch rule: any options preset with a "true"-flagged feature uses
|
||||
// the _All_True family (rich graph, opt-out AcBinarySerializable attribute matches).
|
||||
MakeAcBinary(testData, defaultOptions, "Default"),
|
||||
//MakeAcBinary(testData, binaryDefaultNoSgenOption, "Default"),
|
||||
//MakeAcBinary(testData, AcBinarySerializerOptions.WithoutReferenceHandling, "NoRef"),
|
||||
//MakeAcBinary(testData, binaryNoInternOption, "NoIntern"),
|
||||
|
||||
// AcBinary via IBufferWriter (reused ArrayBufferWriter — long-running service / batch scenario)
|
||||
MakeAcBinaryBufferWriter(testData, binaryFastModeOption, "FastMode"),
|
||||
|
||||
// AcBinary via IBufferWriter (FRESH ArrayBufferWriter per call — one-shot scenario).
|
||||
// 4 KB chunk size from binaryFastModeBufWrChunk — minimises the per-call ArrayBufferWriter
|
||||
// allocation. Optimum for this scenario.
|
||||
MakeAcBinaryFreshBufferWriter(testData, binaryFastModeBufWrChunk, "FastMode (4KB)"),
|
||||
|
||||
// AcBinary chunked-streaming over an IN-MEMORY Pipe (no kernel transport). Side-by-side with the
|
||||
// Byte[] / IBufferWriter rows above this shows the chunked-streaming framework's pure CPU cost
|
||||
// (no NamedPipe loopback noise) vs the simpler in-process serialize-then-deserialize patterns.
|
||||
// The IO column shows "Pipe(in-mem)" — distinct from the NamedPipe AsyncPipe rows in [P] mode.
|
||||
MakeAcBinaryInMemoryPipe(testData, binaryFastModePipeChunkInMem, "FastMode (PipeChunk)"),
|
||||
|
||||
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport, no kernel, no Pipe). Apples-to-
|
||||
// apples baseline for the in-memory chunked row above: same in-memory pattern, but raw byte[] vs
|
||||
// chunked-streaming wire format. The IO column shows "Bytes(in-mem)".
|
||||
MakeAcBinaryInMemoryRaw(testData, binaryFastModePipeChunkInMem, "FastMode (PipeRaw)"),
|
||||
|
||||
// AsyncPipe streaming over kernel NamedPipe (AcBinaryNamedPipeBenchmark) is intentionally OMITTED
|
||||
// here — run it via the dedicated AsyncPipe menu [P] / CLI mode for isolated kernel-transport
|
||||
// measurements.
|
||||
|
||||
// ============================================================
|
||||
// MemoryPack — three I/O modes for apples-to-apples comparison
|
||||
// ============================================================
|
||||
// MemPack uses _All_False (see FastestByte-mode comment above for rationale).
|
||||
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
new MemoryPackBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
new MemoryPackFreshBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
|
||||
// ============================================================
|
||||
// MessagePack — for legacy comparison
|
||||
// ============================================================
|
||||
#if !AYCODE_NATIVEAOT
|
||||
// MessagePack v3's DynamicGenericResolver uses Activator.CreateInstance on trimmed
|
||||
// ListFormatter<T> et al. — fails under NativeAOT publish with "No parameterless constructor".
|
||||
// Excluded from the AOT build; available for regular JIT runs only.
|
||||
new MessagePackBenchmark<TestOrder_All_False>(orderFalse, "ContractBased"),
|
||||
#endif
|
||||
|
||||
// System.Text.Json (commented — JSON serializer for reference; not in active suite)
|
||||
//new SystemTextJsonBenchmark<TestOrder_All_False>(orderFalse, "Default")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces a full GC cycle at a phase boundary in the benchmark loop. Two-pass collect with finalizer drain
|
||||
/// in between: the first pass moves managed garbage to the finalization queue, <c>WaitForPendingFinalizers</c>
|
||||
/// runs the finalizers, the second pass reclaims any objects the finalizers released. After this returns the
|
||||
/// heap is in a known-quiescent state — the next warmup/measurement phase starts on a clean slate, isolated
|
||||
/// from the previous phase's residual allocations (write-buffer pools, intern cache, write-plan arrays, etc.).
|
||||
/// Called between every Ser-phase / Des-phase boundary in <see cref="RunBenchmarksForTestData"/>.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
internal static void ForceGcCollect()
|
||||
{
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the action <paramref name="iterations"/> times for <see cref="Configuration.BenchmarkSamples"/> independent samples,
|
||||
/// returning the median, min, and max elapsed time. Multi-sample design reduces single-run variance
|
||||
/// from ~±15% to ~±5% by smoothing transient effects (background activity, thermal/turbo state).
|
||||
/// When <see cref="Configuration.BenchmarkSamples"/> <= 1, falls back to single-sample timing (Debug / quick mode).
|
||||
/// When <paramref name="progressLabel"/> is non-null, emits in-place <c>\r</c> progress updates so a
|
||||
/// stuck benchmark (e.g. deadlocked NamedPipe row) is visibly stuck at a specific %% rather than
|
||||
/// silently hanging.
|
||||
///
|
||||
/// Stabilization (added 2026-05-07):
|
||||
/// 1) Pilot sample is run BEFORE the recorded loop and discarded. The first measurement after
|
||||
/// warmup tends to absorb residual JIT bookkeeping and GC bookkeeping; dropping it tightens
|
||||
/// the min/max range without throwing away signal (the median is the SAME data as before).
|
||||
/// 2) GC.Collect / WaitForPendingFinalizers / GC.Collect runs BEFORE every recorded sample.
|
||||
/// Without this, GC pressure from sample N occasionally triggered a Gen-2 pause inside
|
||||
/// sample N+1, painting it as an outlier; collecting up-front gives every sample the
|
||||
/// same starting heap shape.
|
||||
/// 3) Returns (median, min, max) so the caller can surface the inter-sample range — visible
|
||||
/// noise floor for the row, replacing the previous "median only" view.
|
||||
/// </summary>
|
||||
internal static (double medianMs, double minMs, double maxMs, double stdDevMs) RunTimed(Action action, int iterations, string? progressLabel = null)
|
||||
{
|
||||
var samples = Configuration.BenchmarkSamples;
|
||||
if (samples <= 1)
|
||||
{
|
||||
// Single-sample fast path (Debug or trivial run) — no allocation, no sort, no stddev.
|
||||
var sw = Stopwatch.StartNew();
|
||||
RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0);
|
||||
sw.Stop();
|
||||
var ms = sw.Elapsed.TotalMilliseconds;
|
||||
EndProgress(progressLabel, ms);
|
||||
return (ms, ms, ms, 0);
|
||||
}
|
||||
|
||||
// Pilot sample (discarded). Counts as sample index 0 of (samples + 1) for progress display
|
||||
// so the user sees an extra "warmup-ish" tick before the recorded samples start.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var pilotSw = Stopwatch.StartNew();
|
||||
RunWithProgress(action, iterations, progressLabel, samples + 1, sampleIndex: 0);
|
||||
pilotSw.Stop();
|
||||
// intentionally not stored
|
||||
|
||||
var times = new double[samples];
|
||||
for (var s = 0; s < samples; s++)
|
||||
{
|
||||
// Per-sample GC settle. Forces every sample to start from the same heap state, so
|
||||
// a Gen-2 pause caused by the previous sample doesn't bleed into the next sample's
|
||||
// timing. Cost is paid OUTSIDE the Stopwatch window — no impact on the measurement.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
// Inter-sample thermal-settle: CPU boost-clock can drop mid-batch under sustained load
|
||||
// (e.g. 10×250ms = 2.5 sec burst). InterSampleSettleMs lets the boost-clock state
|
||||
// settle so later samples don't read systematically slower than early ones. Skip before
|
||||
// the first sample (no prior heat to settle from). Set to 0 in Configuration to disable.
|
||||
if (s > 0 && Configuration.InterSampleSettleMs > 0)
|
||||
Thread.Sleep(Configuration.InterSampleSettleMs);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
RunWithProgress(action, iterations, progressLabel, samples + 1, sampleIndex: s + 1);
|
||||
sw.Stop();
|
||||
times[s] = sw.Elapsed.TotalMilliseconds;
|
||||
}
|
||||
|
||||
// Capture min/max/sum/sumSq BEFORE sort to avoid order ambiguity (Array.Sort is in-place).
|
||||
var minMs = double.MaxValue;
|
||||
var maxMs = double.MinValue;
|
||||
var sum = 0.0;
|
||||
var sumSq = 0.0;
|
||||
|
||||
for (var i = 0; i < times.Length; i++)
|
||||
{
|
||||
var t = times[i];
|
||||
sum += t;
|
||||
sumSq += t * t;
|
||||
if (t < minMs) minMs = t;
|
||||
if (t > maxMs) maxMs = t;
|
||||
}
|
||||
// Population stddev (not sample-stddev — we treat the captured samples as the population for
|
||||
// CV computation). variance = E[X²] - E[X]² with Math.Max(0, ...) guard against tiny negative
|
||||
// values from FP rounding when samples are nearly identical.
|
||||
var mean = sum / times.Length;
|
||||
var variance = (sumSq / times.Length) - (mean * mean);
|
||||
var stdDevMs = Math.Sqrt(Math.Max(0.0, variance));
|
||||
|
||||
Array.Sort(times);
|
||||
|
||||
// Trimmed median: when samples >= 4, drop the single min and single max (sorted-array
|
||||
// first and last) and compute median on the remaining (samples - 2) entries. Removes the
|
||||
// worst per-sample contamination (a thermal spike, OS preempt, or a GC pause that escaped
|
||||
// the per-sample GC.Collect settle) without throwing away too much signal. The min/max /
|
||||
// stdDev outputs still reflect the FULL sample population — the trim affects only the
|
||||
// headline median figure, so the visible range still shows the actual measurement extremes.
|
||||
var trimStart = samples >= 4 ? 1 : 0;
|
||||
var trimCount = samples >= 4 ? samples - 2 : samples;
|
||||
var medianMs = trimCount % 2 == 1
|
||||
? times[trimStart + trimCount / 2]
|
||||
: (times[trimStart + trimCount / 2 - 1] + times[trimStart + trimCount / 2]) / 2.0;
|
||||
EndProgress(progressLabel, medianMs);
|
||||
|
||||
return (medianMs, minMs, maxMs, stdDevMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-cell adaptive iteration calibration. Runs a 100-iter measurement after warmup and computes
|
||||
/// how many iterations are needed to reach <see cref="Configuration.TargetSampleMs"/> wall-clock per sample.
|
||||
/// Returns iter rounded UP to the nearest 1000, floored at 1000 (the prior fixed minimum) and
|
||||
/// ceiling-capped at 200_000 (sanity bound for pathologically fast ops). In Debug single-sample mode
|
||||
/// (<c>Configuration.BenchmarkSamples <= 1</c>) returns the global <see cref="Configuration.TestIterations"/> unchanged —
|
||||
/// calibration overhead is unjustified there. Calibration runs OUTSIDE the timed sample loop and
|
||||
/// does NOT count toward warmup; its sole purpose is to measure per-op cost.
|
||||
/// </summary>
|
||||
internal static int CalibrateIterations(Action action, int targetMs)
|
||||
{
|
||||
if (Configuration.BenchmarkSamples <= 1) return Configuration.TestIterations; // Debug fast path
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
const int calibIter = 100;
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < calibIter; i++) action();
|
||||
sw.Stop();
|
||||
var ms = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// Pathologically-fast op below Stopwatch resolution — cap at ceiling (further calibration won't help).
|
||||
if (ms <= 0.0001) return 200_000;
|
||||
|
||||
var iterPerMs = calibIter / ms;
|
||||
var raw = (int)Math.Ceiling(targetMs * iterPerMs);
|
||||
// Round UP to nearest 1000 — keeps numbers human-readable in the markdown output.
|
||||
var rounded = ((raw + 999) / 1000) * 1000;
|
||||
|
||||
return rounded switch
|
||||
{
|
||||
< 1000 => 1000,
|
||||
> 200_000 => 200_000,
|
||||
_ => rounded
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps
|
||||
/// timing samples pure. When <paramref name="processWide"/> is <c>true</c>, uses
|
||||
/// <see cref="GC.GetTotalAllocatedBytes"/> instead of <see cref="GC.GetAllocatedBytesForCurrentThread"/>
|
||||
/// — needed for round-trip-only benchmarks (NamedPipe etc.) where the work happens across multiple
|
||||
/// threads (server-side <c>new byte[len]</c> buffers, drain-pump-thread allocations). Per-thread mode
|
||||
/// is slightly cleaner for in-memory benchmarks; process-wide mode is slightly noisier (background
|
||||
/// threads / GC bookkeeping leak in) but over 1000 iterations the signal dominates.
|
||||
/// </summary>
|
||||
internal static long MeasureAllocation(Action action, int iterations, string? progressLabel = null, bool processWide = false)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var before = processWide ? GC.GetTotalAllocatedBytes(precise: true) : GC.GetAllocatedBytesForCurrentThread();
|
||||
RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0);
|
||||
var after = processWide ? GC.GetTotalAllocatedBytes(precise: true) : GC.GetAllocatedBytesForCurrentThread();
|
||||
sw.Stop();
|
||||
EndProgress(progressLabel, sw.Elapsed.TotalMilliseconds);
|
||||
return (after - before) / iterations;
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Progress reporting — \r-driven in-place updates so a stuck benchmark surfaces the exact phase
|
||||
// and % where it stopped, instead of appearing as a silent hang. Used by RunTimed and the
|
||||
// MeasureAllocation* helpers when the caller passes a non-null progressLabel.
|
||||
// ============================================================================================
|
||||
|
||||
// Tracks the longest line written by the current progress session, so EndProgress can clear
|
||||
// any leftover characters from a prior longer line (avoids "ghost" trailing chars after \r).
|
||||
private static int _progressLastLineLen;
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="action"/> <paramref name="iterations"/> times, emitting \r-overwriting
|
||||
/// progress every ~10% (approx. 10 progress prints per sample). When <paramref name="label"/>
|
||||
/// is null, runs without any progress output (zero overhead beyond a null check per iter).
|
||||
/// </summary>
|
||||
private static void RunWithProgress(Action action, int iterations, string? label, int samples, int sampleIndex)
|
||||
{
|
||||
if (label is null)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++) action();
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch-based progress emit — ~10 progress prints per sample. The inner loop is branchless
|
||||
// (no per-iter modulo / progress check), so the per-iter overhead is bare `action()` cost.
|
||||
// The outer loop drives the batches; progress emit happens once per batch on the boundary.
|
||||
// This keeps sub-µs ops cleanly measurable — the prior `if ((i + 1) % step == 0)` check
|
||||
// added a 1-2 cycle per-iter branch that distorted hot loops near the Stopwatch resolution.
|
||||
var step = Math.Max(1, iterations / 10);
|
||||
var done = 0;
|
||||
while (done < iterations)
|
||||
{
|
||||
var batch = Math.Min(step, iterations - done);
|
||||
|
||||
// Inner tight loop: no progress check, no modulo. Just the measured action() calls.
|
||||
for (var i = 0; i < batch; i++) action();
|
||||
done += batch;
|
||||
|
||||
var pct = (int)(done * 100L / iterations);
|
||||
var line = samples > 1
|
||||
? $" > {label} sample {sampleIndex + 1}/{samples} {pct,3}% ({done}/{iterations})"
|
||||
: $" > {label} {pct,3}% ({done}/{iterations})";
|
||||
|
||||
System.Console.Write('\r');
|
||||
System.Console.Write(line);
|
||||
|
||||
if (line.Length < _progressLastLineLen)
|
||||
System.Console.Write(new string(' ', _progressLastLineLen - line.Length));
|
||||
|
||||
_progressLastLineLen = line.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes a progress line cleanly: clears any leftover chars and writes a final "done" line on
|
||||
/// the same row, terminated by \n so subsequent <c>WriteLine</c> calls render below.
|
||||
/// </summary>
|
||||
private static void EndProgress(string? label, double elapsedMs)
|
||||
{
|
||||
if (label is null) return;
|
||||
var done = $" > {label} done in {elapsedMs,7:F1} ms";
|
||||
|
||||
System.Console.Write('\r');
|
||||
System.Console.Write(done);
|
||||
|
||||
if (done.Length < _progressLastLineLen)
|
||||
System.Console.Write(new string(' ', _progressLastLineLen - done.Length));
|
||||
|
||||
System.Console.WriteLine();
|
||||
_progressLastLineLen = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder_All_True is not [MemoryPackable].
|
||||
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.
|
||||
/// </summary>
|
||||
internal static void ValidateMemoryPackSetup()
|
||||
{
|
||||
var typesToCheck = new[] { typeof(TestOrder_All_True) };
|
||||
|
||||
foreach (var type in typesToCheck)
|
||||
{
|
||||
var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any();
|
||||
if (!hasAttr)
|
||||
{
|
||||
System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim.");
|
||||
System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it.");
|
||||
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
|
||||
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
|
||||
/// </summary>
|
||||
internal static List<TestDataSet> FilterByLayer(List<TestDataSet> all, BenchmarkLayer layer)
|
||||
{
|
||||
if (layer == BenchmarkLayer.All) return all.ToList();
|
||||
|
||||
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||||
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
|
||||
var comprehensiveExtras = new string[] { /* P2 */ };
|
||||
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
|
||||
var edgeExtras = new string[] { /* P3 */ };
|
||||
|
||||
return layer switch
|
||||
{
|
||||
BenchmarkLayer.Core => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(),
|
||||
BenchmarkLayer.Comprehensive => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(),
|
||||
BenchmarkLayer.Edge => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(),
|
||||
// Single-cell A/B mini-suite filters — match by case-insensitive prefix on Name.
|
||||
// Use case: tight optimization-iteration loop on one specific cell (e.g. `dotnet run -- repeated`
|
||||
// or interactive menu shortcut), avoiding the full ~110 sec suite when only one cell is in scope.
|
||||
BenchmarkLayer.Small => all.Where(t => t.Name.StartsWith("Small", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Medium => all.Where(t => t.Name.StartsWith("Medium", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Large => all.Where(t => t.Name.StartsWith("Large", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Repeated => all.Where(t => t.Name.StartsWith("Repeated", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Deep => all.Where(t => t.Name.StartsWith("Deep", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
_ => all.ToList()
|
||||
};
|
||||
|
||||
static bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(name.StartsWith);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
internal static class BenchmarkTestDataProvider
|
||||
{
|
||||
internal static List<TestDataSet> CreateTestDataSets()
|
||||
{
|
||||
return new List<TestDataSet>
|
||||
{
|
||||
CreateSmallTestData(),
|
||||
CreateMediumTestData(),
|
||||
CreateLargeTestData(),
|
||||
CreateRepeatedStringsTestData(),
|
||||
CreateDeepNestedTestData()
|
||||
};
|
||||
}
|
||||
|
||||
internal static TestOrder CreateProfilerOrder()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
return TestDataFactory.CreateOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 4,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser);
|
||||
}
|
||||
|
||||
private static TestDataSet CreateSmallTestData()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
|
||||
var order = TestDataFactory.CreateOrder(
|
||||
itemCount: 2,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 2,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet("Small (2x2x2x2)", order, iidRefPercent: 10);
|
||||
}
|
||||
|
||||
private static TestDataSet CreateMediumTestData()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
var sharedPreferences = new UserPreferences
|
||||
{
|
||||
Theme = "dark",
|
||||
Language = "en-US",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "weekly"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = TestDataFactory.CreateOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 4,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedMetadata: sharedMeta,
|
||||
sharedPreferences: sharedPreferences);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet("Medium (3x3x3x4)", order, iidRefPercent: 10);
|
||||
}
|
||||
|
||||
private static TestDataSet CreateLargeTestData()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
|
||||
var sharedPreferences = new UserPreferences
|
||||
{
|
||||
Theme = "light",
|
||||
Language = "de-DE",
|
||||
NotificationsEnabled = false,
|
||||
EmailDigestFrequency = "daily"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = TestDataFactory.CreateOrder(
|
||||
itemCount: 5,
|
||||
palletsPerItem: 5,
|
||||
measurementsPerPallet: 5,
|
||||
pointsPerMeasurement: 10,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedPreferences: sharedPreferences);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet("Large (5x5x5x10)", order, iidRefPercent: 10);
|
||||
}
|
||||
|
||||
private static TestDataSet CreateRepeatedStringsTestData()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
|
||||
var sharedTag = TestDataFactory.CreateTag("RepeatedTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("repeateduser");
|
||||
|
||||
var sharedPreferences = new UserPreferences
|
||||
{
|
||||
Theme = "dark",
|
||||
Language = "en-US",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "weekly"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = TestDataFactory.CreateOrder(
|
||||
itemCount: 10,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 2,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedPreferences: sharedPreferences);
|
||||
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
item.Status = TestStatus.Processing;
|
||||
item.ProductName = "CommonProductName_RepeatedForTesting";
|
||||
}
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet("Repeated Strings (10 items)", order, iidRefPercent: 10);
|
||||
}
|
||||
|
||||
private static TestDataSet CreateDeepNestedTestData()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
|
||||
var sharedTag = TestDataFactory.CreateTag("DeepTag");
|
||||
var sharedUser = TestDataFactory.CreateUser("deepuser");
|
||||
var sharedCategory = TestDataFactory.CreateCategory("DeepCategory");
|
||||
|
||||
var sharedPreferences = new UserPreferences
|
||||
{
|
||||
Theme = "light",
|
||||
Language = "fr-FR",
|
||||
NotificationsEnabled = false,
|
||||
EmailDigestFrequency = "monthly"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = TestDataFactory.CreateOrder(
|
||||
itemCount: 2,
|
||||
palletsPerItem: 4,
|
||||
measurementsPerPallet: 4,
|
||||
pointsPerMeasurement: 8,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedPreferences: sharedPreferences,
|
||||
sharedCategory: sharedCategory);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet("Deep Nested (2x4x4x8)", order, iidRefPercent: 10);
|
||||
}
|
||||
|
||||
private static void ClearDeepLevelRefs(TestOrder order)
|
||||
{
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
foreach (var pallet in item.Pallets)
|
||||
{
|
||||
pallet.Tag = null;
|
||||
pallet.Inspector = null;
|
||||
pallet.Category = null;
|
||||
|
||||
foreach (var measurement in pallet.Measurements)
|
||||
{
|
||||
measurement.Tag = null;
|
||||
measurement.Operator = null;
|
||||
|
||||
foreach (var point in measurement.Points)
|
||||
{
|
||||
point.Tag = null;
|
||||
point.Verifier = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestDataSet
|
||||
{
|
||||
public string Name { get; }
|
||||
public TestOrder Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Percentage of IId shared references in the data (0-100).
|
||||
/// Higher values mean more deduplication benefit for Default mode.
|
||||
/// </summary>
|
||||
public int IIdRefPercent { get; }
|
||||
|
||||
public TestDataSet(string name, TestOrder order, int iidRefPercent = 0)
|
||||
{
|
||||
Name = name;
|
||||
Order = order;
|
||||
IIdRefPercent = iidRefPercent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets display name including IId ref percentage if set.
|
||||
/// </summary>
|
||||
public string DisplayName => IIdRefPercent > 0
|
||||
? $"{Name} [{IIdRefPercent}% IId refs]"
|
||||
: Name;
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration state for the benchmark application. Holds compile-time and runtime constants,
|
||||
/// per-run mutable settings (WireMode, charset, iteration counts), and the attribute-flag
|
||||
/// aggregation that drives the per-row Options column. Split out from <c>Program.cs</c> so the
|
||||
/// entry-point file can focus on UX-flow and benchmark orchestration; everything in this class
|
||||
/// is config / state — no benchmark logic. Single instance (static class) — the application is
|
||||
/// a one-shot process, no multi-tenant state isolation needed.
|
||||
/// </summary>
|
||||
internal static class Configuration
|
||||
{
|
||||
#if AYCODE_NATIVEAOT
|
||||
internal const string BuildConfiguration = "NativeAOT";
|
||||
#elif DEBUG
|
||||
internal const string BuildConfiguration = "Debug";
|
||||
#elif SGEN_ONLY
|
||||
internal const string BuildConfiguration = "SGenOnly";
|
||||
#else
|
||||
internal const string BuildConfiguration = "Release";
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
internal static int WarmupIterations = 0;
|
||||
internal static int TestIterations = 1;
|
||||
internal static int BenchmarkSamples = 1; // Debug: single sample, fast iteration
|
||||
#else
|
||||
internal static int WarmupIterations = 5000; //10000 — per-phase (Ser AND Des get their own warmup separately)
|
||||
internal static int TestIterations = 1000; //1000
|
||||
internal static int BenchmarkSamples = 10;
|
||||
#endif
|
||||
|
||||
// Interactive settings: selected AcBinary wire mode for benchmark runs.
|
||||
// 1 = Compact, 2 = Fast
|
||||
internal static WireMode SelectedWireMode = WireMode.Compact;
|
||||
|
||||
// Engine / IO mode / Dispatch mode identifiers → Benchmarks/BenchmarkEnums.cs (typed enums with ToDisplay)
|
||||
|
||||
// Single source of truth for the chunk size used by ALL pipe-related benchmarks (NamedPipe PipeChunk,
|
||||
// NamedPipe PipeRaw, in-memory Pipe, in-memory RawMem) AND the NamedPipe server's inBufferSize/outBufferSize.
|
||||
// Same value across both layers ensures apples-to-apples comparison: chunked-streaming chunk-on-wire size
|
||||
// matches the kernel pipe-buffer slot exactly. Tweak HERE when experimenting; do NOT scatter chunkSize
|
||||
// overrides across individual benchmark rows.
|
||||
internal const int PipeChunkSize = 4096;
|
||||
|
||||
// Per-cell adaptive iteration target wall-clock duration. Each Ser/Des function calibrates its
|
||||
// own iteration count post-warmup so the sample batch lands in this range — equalizes the
|
||||
// per-sample window across cells of vastly different per-op cost (Small ~6 ns/op vs Large
|
||||
// ~140 µs/op). Below ~100 ms Stopwatch precision and OS preempt spikes start to dominate.
|
||||
internal const int TargetSampleMs = 250;
|
||||
|
||||
// CV (coefficient of variation = stddev / mean) threshold above which a row's range is flagged
|
||||
// as "unstable" in the markdown output (⚠️ marker). 3% is a reasonable noise-floor expectation
|
||||
// for stabilized in-memory benchmarks; rows above it should be discounted when reading
|
||||
// sub-3% inter-engine deltas.
|
||||
internal const double UnstableCVThreshold = 0.03;
|
||||
|
||||
// Lower-bound CV threshold for micro-optimization measurement reliability. Rows with CV in
|
||||
// the (MicroOptCVThreshold, UnstableCVThreshold] range get a softer "⚠️micro" flag — they are
|
||||
// not unstable enough to be entirely dismissable, but sub-2% inter-engine deltas observed on
|
||||
// such a row are at the edge of the noise floor and should be cross-checked (re-run, BDN).
|
||||
// Use case: micro-opt sprints where a ~1-2% signal lives below the unstable threshold but the
|
||||
// row's own CV is still high enough to make that signal suspect.
|
||||
internal const double MicroOptCVThreshold = 0.015;
|
||||
|
||||
// Inter-sample cool-down delay (ms) inserted between recorded samples in the timed loop.
|
||||
// Mitigates CPU thermal-throttling drift across a sustained burst (e.g. 10×250ms = 2.5 sec):
|
||||
// without it, boost-clock can drop mid-batch on thermally-constrained hosts (laptops esp.),
|
||||
// and the later samples in the batch read systematically slower than the early ones. 50ms is
|
||||
// enough for boost-clock state to settle but cheap in total (~500ms / cell) — quick-bench
|
||||
// workflow is not meaningfully slower.
|
||||
internal const int InterSampleSettleMs = 50;
|
||||
|
||||
// JIT-tier-promotion drain delay between warmup and measurement.
|
||||
// - JIT mode (RuntimeFeature.IsDynamicCodeCompiled == true): tiered JIT promotes hot methods
|
||||
// in a background thread; we wait briefly for the queue to drain so the first measurement
|
||||
// sample doesn't catch a Tier-0 → Tier-1 transition mid-flight.
|
||||
// - AOT mode (NativeAOT publish): no dynamic compilation happens; the sleep is pure noise.
|
||||
// 250ms (vs the historical 3000ms) is sufficient for a few-method working set under .NET 9's
|
||||
// tiered JIT — empirically the queue drains in <100ms for the bench's hot path.
|
||||
internal static int JitSleep => RuntimeFeature.IsDynamicCodeCompiled ? 250 : 0;
|
||||
|
||||
// OptionsPreset values are passed per-instance (constructor argument), not constants —
|
||||
// each CreateSerializers call line specifies its own preset name (e.g. "FastMode", "NoIntern").
|
||||
|
||||
internal static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable name for the currently-active <c>BenchmarkTestDataProvider.LongStringSuffix</c>
|
||||
/// charset. Returns "Custom" when the suffix doesn't match any of the predefined
|
||||
/// <see cref="CharsetSuffixes"/> constants. Used in menu state display, console run header, and
|
||||
/// the .LLM / .log output headers so per-charset bench files are self-documenting.
|
||||
/// </summary>
|
||||
internal static string GetCurrentCharsetName()
|
||||
{
|
||||
var s = BenchmarkTestDataProvider.LongStringSuffix;
|
||||
|
||||
return s switch
|
||||
{
|
||||
CharsetSuffixes.AsciiFix => nameof(CharsetSuffixes.AsciiFix),
|
||||
CharsetSuffixes.AsciiShort => nameof(CharsetSuffixes.AsciiShort),
|
||||
CharsetSuffixes.AsciiLong => nameof(CharsetSuffixes.AsciiLong),
|
||||
CharsetSuffixes.Latin1Fix => nameof(CharsetSuffixes.Latin1Fix),
|
||||
CharsetSuffixes.Latin1Short => nameof(CharsetSuffixes.Latin1Short),
|
||||
CharsetSuffixes.Latin1Long => nameof(CharsetSuffixes.Latin1Long),
|
||||
CharsetSuffixes.CjkBmpShort => nameof(CharsetSuffixes.CjkBmpShort),
|
||||
CharsetSuffixes.CjkBmpLong => nameof(CharsetSuffixes.CjkBmpLong),
|
||||
CharsetSuffixes.CyrillicShort => nameof(CharsetSuffixes.CyrillicShort),
|
||||
CharsetSuffixes.CyrillicLong => nameof(CharsetSuffixes.CyrillicLong),
|
||||
CharsetSuffixes.MixedShort => nameof(CharsetSuffixes.MixedShort),
|
||||
CharsetSuffixes.MixedLong => nameof(CharsetSuffixes.MixedLong),
|
||||
_ => "Custom"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Interactive console menu for the benchmark application. Shown when the user runs without CLI args:
|
||||
/// top-level layer/mode selection + nested Settings sub-menus (iteration counts, wire mode, charset).
|
||||
/// All settings mutate <see cref="Configuration"/> in place; the menu loop returns control to the
|
||||
/// caller (<c>Program.Main</c>) once the user picks a benchmark layer or quits.
|
||||
/// </summary>
|
||||
internal static class Menu
|
||||
{
|
||||
/// <summary>
|
||||
/// Interactive menu shown when no CLI args. Returns the (layer, serializerMode) tuple, or null on Quit.
|
||||
/// Loops on settings-changes ([S]) — user is returned to this menu after modifying iteration counts.
|
||||
/// </summary>
|
||||
internal static (BenchmarkLayer layer, SerializerSelectionMode serializerMode)? ShowInteractiveMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ AcBinary Benchmark Suite ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("Select benchmark layer:");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" [1] Core — daily iteration");
|
||||
System.Console.WriteLine(" [2] Comprehensive — release validation");
|
||||
System.Console.WriteLine(" [3] Edge cases — refactor verification");
|
||||
System.Console.WriteLine(" [A] All layers");
|
||||
System.Console.WriteLine(" [F] FastestByte — AcBinary FastMode Byte[] vs MemoryPack Byte[] only (tight optimization loop)");
|
||||
System.Console.WriteLine(" [P] AsyncPipe — streaming I/O isolation (only AsyncPipe, all test data)");
|
||||
System.Console.WriteLine($" [S] Settings — Iteration / WireMode (current: {Configuration.SelectedWireMode})");
|
||||
System.Console.WriteLine(" [Q] Quit");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1': return (BenchmarkLayer.Core, SerializerSelectionMode.Standard);
|
||||
case '2': return (BenchmarkLayer.Comprehensive, SerializerSelectionMode.Standard);
|
||||
case '3': return (BenchmarkLayer.Edge, SerializerSelectionMode.Standard);
|
||||
case 'a': return (BenchmarkLayer.All, SerializerSelectionMode.Standard);
|
||||
case 'f': return (BenchmarkLayer.All, SerializerSelectionMode.FastestByte);
|
||||
case 'p': return (BenchmarkLayer.All, SerializerSelectionMode.AsyncPipe);
|
||||
case 's':
|
||||
ShowSettingsMenu();
|
||||
continue; // re-display the main menu after settings update
|
||||
case 'q': return null;
|
||||
default: return (BenchmarkLayer.All, SerializerSelectionMode.Standard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings sub-menu — dispatches to per-area sub-menus (iteration counts, wire mode, charset).
|
||||
/// Returns to the caller (which re-displays the main menu) when [B]ack is pressed.
|
||||
/// </summary>
|
||||
private static void ShowSettingsMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Settings");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine(" [1] Iteration — Warmup / Iterations / Samples");
|
||||
System.Console.WriteLine($" [2] WireMode — current: {Configuration.SelectedWireMode}");
|
||||
System.Console.WriteLine($" [3] Charset — current: {Configuration.GetCurrentCharsetName()}");
|
||||
System.Console.WriteLine(" [B] Back");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1':
|
||||
ShowIterationSettingsMenu();
|
||||
break;
|
||||
case '2':
|
||||
ShowWireModeSettingsMenu();
|
||||
break;
|
||||
case '3':
|
||||
ShowCharsetSettingsMenu();
|
||||
break;
|
||||
case 'b':
|
||||
return;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowCharsetSettingsMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Charset settings — long-string suffix profile");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine($"Current: {Configuration.GetCurrentCharsetName()}");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" All *Short = 40 char, all *Long = 280 char (= Short × 7) — length-consistent across charsets.");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" [1] AsciiFix — empty suffix; baseline-only short values → FixStrAscii tier");
|
||||
System.Console.WriteLine(" [2] AsciiShort — 40 char pure ASCII (quic × 8) → StringAscii tier");
|
||||
System.Console.WriteLine(" [3] AsciiLong — 280 char pure ASCII → StringAscii tier");
|
||||
System.Console.WriteLine(" [4] Latin1Fix — 5 char Hungarian (árví) → FixStr-lean tier");
|
||||
System.Console.WriteLine(" [5] Latin1Short — 40 char Hungarian (árví × 8) → StringSmall tier");
|
||||
System.Console.WriteLine(" [6] Latin1Long — 280 char Hungarian (default) → StringMedium tier");
|
||||
System.Console.WriteLine(" [7] CjkBmpShort — 40 char CJK BMP (3-byte runs) → StringSmall tier");
|
||||
System.Console.WriteLine(" [8] CjkBmpLong — 280 char CJK BMP → StringMedium tier");
|
||||
System.Console.WriteLine(" [9] CyrillicShort — 40 char Cyrillic (2-byte runs) → StringSmall tier");
|
||||
System.Console.WriteLine(" [0] CyrillicLong — 280 char Cyrillic → StringMedium tier");
|
||||
System.Console.WriteLine(" [A] MixedShort — 40 char multi-codepage → StringSmall tier");
|
||||
System.Console.WriteLine(" [C] MixedLong — 280 char multi-codepage → StringMedium tier");
|
||||
System.Console.WriteLine(" [B] Back");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiFix;
|
||||
System.Console.WriteLine("✓ Charset set to AsciiFix");
|
||||
return;
|
||||
case '2':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiShort;
|
||||
System.Console.WriteLine("✓ Charset set to AsciiShort");
|
||||
return;
|
||||
case '3':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiLong;
|
||||
System.Console.WriteLine("✓ Charset set to AsciiLong");
|
||||
return;
|
||||
case '4':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Fix;
|
||||
System.Console.WriteLine("✓ Charset set to Latin1Fix");
|
||||
return;
|
||||
case '5':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
|
||||
System.Console.WriteLine("✓ Charset set to Latin1Short");
|
||||
return;
|
||||
case '6':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Long;
|
||||
System.Console.WriteLine("✓ Charset set to Latin1Long");
|
||||
return;
|
||||
case '7':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CjkBmpShort;
|
||||
System.Console.WriteLine("✓ Charset set to CjkBmpShort");
|
||||
return;
|
||||
case '8':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CjkBmpLong;
|
||||
System.Console.WriteLine("✓ Charset set to CjkBmpLong");
|
||||
return;
|
||||
case '9':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CyrillicShort;
|
||||
System.Console.WriteLine("✓ Charset set to CyrillicShort");
|
||||
return;
|
||||
case '0':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CyrillicLong;
|
||||
System.Console.WriteLine("✓ Charset set to CyrillicLong");
|
||||
return;
|
||||
case 'a':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.MixedShort;
|
||||
System.Console.WriteLine("✓ Charset set to MixedShort");
|
||||
return;
|
||||
case 'c':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.MixedLong;
|
||||
System.Console.WriteLine("✓ Charset set to MixedLong");
|
||||
return;
|
||||
case 'b':
|
||||
return;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowIterationSettingsMenu()
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Iteration settings — press Enter to keep current value");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine();
|
||||
|
||||
Configuration.WarmupIterations = PromptInt("WarmupIterations", Configuration.WarmupIterations, min: 0);
|
||||
Configuration.TestIterations = PromptInt("TestIterations ", Configuration.TestIterations, min: 1);
|
||||
Configuration.BenchmarkSamples = PromptInt("BenchmarkSamples", Configuration.BenchmarkSamples, min: 1);
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine($"✓ Iteration settings updated: Warmup={Configuration.WarmupIterations} | Iterations={Configuration.TestIterations} | Samples={Configuration.BenchmarkSamples}");
|
||||
}
|
||||
|
||||
private static void ShowWireModeSettingsMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("WireMode settings");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine($"Current: {Configuration.SelectedWireMode}");
|
||||
System.Console.WriteLine(" [1] Compact");
|
||||
System.Console.WriteLine(" [2] Fast");
|
||||
System.Console.WriteLine(" [B] Back");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1':
|
||||
Configuration.SelectedWireMode = WireMode.Compact;
|
||||
System.Console.WriteLine("✓ WireMode set to Compact");
|
||||
return;
|
||||
case '2':
|
||||
Configuration.SelectedWireMode = WireMode.Fast;
|
||||
System.Console.WriteLine("✓ WireMode set to Fast");
|
||||
return;
|
||||
case 'b':
|
||||
return;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user for an integer with a default (current value). Returns the current value if
|
||||
/// the user presses Enter on empty input or if parsing fails / value is below the minimum.
|
||||
/// </summary>
|
||||
private static int PromptInt(string name, int currentValue, int min)
|
||||
{
|
||||
System.Console.Write($" {name} [{currentValue}]: ");
|
||||
|
||||
var input = System.Console.ReadLine()?.Trim() ?? "";
|
||||
if (input.Length == 0) return currentValue;
|
||||
|
||||
if (int.TryParse(input, out var newValue) && newValue >= min) return newValue;
|
||||
|
||||
System.Console.WriteLine($" ! Invalid value (need int ≥ {min}) — keeping {currentValue}");
|
||||
return currentValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
using AyCode.Core.Compression;
|
||||
using AyCode.Core.Compression;
|
||||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
#if !AYCODE_NATIVEAOT
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
#endif
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
using System.IO.Pipes;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -25,843 +34,146 @@ namespace AyCode.Core.Serializers.Console;
|
|||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
|
||||
|
||||
#if DEBUG
|
||||
private const string BuildConfiguration = "Debug";
|
||||
#else
|
||||
private const string BuildConfiguration = "Release";
|
||||
#endif
|
||||
|
||||
// Serializer name constants
|
||||
private const string SerializerMessagePack = "MessagePack";
|
||||
private const string SerializerAcBinaryDefault = "AcBinary (Default)";
|
||||
private const string SerializerAcBinaryDefaultNoSgen = "AcBinary (Def, NoSgen)";
|
||||
private const string SerializerAcBinaryNoRef = "AcBinary (NoRef)";
|
||||
private const string SerializerAcBinaryFastMode = "AcBinary (FastMode)";
|
||||
private const string SerializerAcBinaryFastNoSgen = "AcBinary (Fast, NoSgen)";
|
||||
private const string SerializerAcBinaryNoIntern = "AcBinary (NoIntern)";
|
||||
private const string SerializerMemoryPack = "MemoryPack";
|
||||
//private const string SerializerAcBinaryBufferWriter = "AcBinary (BufferWriter)";
|
||||
//private const string SerializerSystemTextJson = "System.Text.Json";
|
||||
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
#if DEBUG
|
||||
private static int WarmupIterations = 0;
|
||||
private static int TestIterations = 1;
|
||||
#else
|
||||
private static int WarmupIterations = 5000;
|
||||
private static int TestIterations = 1000;
|
||||
|
||||
//private static int WarmupIterations = 5000;
|
||||
//private static int TestIterations = 2000;
|
||||
#endif
|
||||
// Configuration (constants, mutable state, attribute-flag aggregation) → Configuration.cs
|
||||
// BuildAcBinary + GetMemPack helpers → Benchmarks/BenchmarkOptions.cs
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// Set console encoding to UTF-8 for proper Unicode character display
|
||||
System.Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
var mode = args.Length > 0 ? args[0].ToLower() : "all";
|
||||
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
|
||||
// Done early so user is told immediately, not after warmup.
|
||||
BenchmarkLoop.ValidateMemoryPackSetup();
|
||||
|
||||
if (mode == "quick")
|
||||
// CLI mode (args provided): run once, parse args, exit. Backward-compatible behaviour.
|
||||
if (args.Length > 0)
|
||||
{
|
||||
WarmupIterations = 5;
|
||||
TestIterations = 100;
|
||||
mode = "all";
|
||||
}
|
||||
if (!TryParseCliArgs(args, out var layer, out var opMode, out var serializerMode))
|
||||
return; // invalid args
|
||||
|
||||
// Profiler mode: warmup only, then exit (for memory profiler analysis)
|
||||
if (mode == "profiler")
|
||||
{
|
||||
RunProfilerMode();
|
||||
BenchmarkLoop.RunBenchmark(layer, opMode, serializerMode);
|
||||
return;
|
||||
}
|
||||
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
System.Console.WriteLine($"Mode: {mode} | Iterations: {TestIterations} | Warmup: {WarmupIterations}");
|
||||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
var allResults = new List<BenchmarkResult>();
|
||||
var testDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
// Interactive mode (no args): loop the menu so the user doesn't have to restart between runs.
|
||||
// Q exits the menu (and the application).
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
|
||||
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
|
||||
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
|
||||
var selection = Menu.ShowInteractiveMenu();
|
||||
if (selection == null) return; // user pressed Q
|
||||
|
||||
var results = RunBenchmarksForTestData(testData, mode);
|
||||
allResults.AddRange(results);
|
||||
}
|
||||
BenchmarkLoop.RunBenchmark(selection.Value.layer, BenchmarkOpMode.All, selection.Value.serializerMode);
|
||||
|
||||
// Print grouped results
|
||||
PrintGroupedResults(allResults, testDataSets);
|
||||
|
||||
// Save results to file
|
||||
SaveResults(allResults, testDataSets);
|
||||
|
||||
System.Console.WriteLine("\n✓ Benchmark complete!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profiler mode: warmup only, then EXIT immediately.
|
||||
/// Usage: dotnet run -- profiler
|
||||
/// </summary>
|
||||
private static void RunProfilerMode()
|
||||
{
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ PROFILER MODE (AcBinary only) ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
var order = BenchmarkTestDataProvider.CreateProfilerOrder();
|
||||
|
||||
var options = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
options.UseStringInterning = StringInterningMode.None;
|
||||
|
||||
byte[] bytes = AcBinarySerializer.Serialize(order, options);
|
||||
// Warmup (fills caches)
|
||||
System.Console.WriteLine("Warming up (1000 iterations)...");
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
_ = AcBinarySerializer.Serialize(order, options);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||||
}
|
||||
|
||||
Thread.Sleep(2000);
|
||||
System.Console.WriteLine("Warmup complete. Caches are now populated.");
|
||||
System.Console.WriteLine();
|
||||
|
||||
// HOT PATH - this is what the profiler should capture!
|
||||
System.Console.WriteLine("Running hot path serialization (1000 iterations for profiling)...");
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
_ = AcBinarySerializer.Serialize(order, options);
|
||||
//_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||||
}
|
||||
|
||||
System.Console.WriteLine("Running hot path deserialization (1000 iterations for profiling)...");
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||||
}
|
||||
|
||||
System.Console.WriteLine("Hot path complete.");
|
||||
System.Console.WriteLine();
|
||||
|
||||
System.Console.WriteLine(">>> ATTACH MEMORY PROFILER NOW <<<");
|
||||
System.Console.WriteLine("Press any key to exit...");
|
||||
System.Console.ReadKey(intercept: true);
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("✓ Profiler mode complete. Exiting now.");
|
||||
}
|
||||
|
||||
#region Benchmark Execution
|
||||
|
||||
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, string mode)
|
||||
{
|
||||
var results = new List<BenchmarkResult>();
|
||||
var serializers = CreateSerializers(testData);
|
||||
|
||||
// Warmup all serializers
|
||||
System.Console.WriteLine($"Warming up ({WarmupIterations} iterations)...");
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
serializer.Warmup(WarmupIterations);
|
||||
}
|
||||
|
||||
// Wait for tiered JIT background compilation to complete
|
||||
Thread.Sleep(3000);
|
||||
|
||||
// Run benchmarks
|
||||
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations)...\n");
|
||||
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
var result = new BenchmarkResult
|
||||
{
|
||||
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
|
||||
SerializerName = serializer.Name,
|
||||
SerializedSize = serializer.SerializedSize
|
||||
};
|
||||
|
||||
if (mode is "all" or "serialize" or "ser")
|
||||
{
|
||||
result.SerializeTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
|
||||
}
|
||||
|
||||
if (mode is "all" or "deserialize" or "des")
|
||||
{
|
||||
result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations);
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
PrintResult(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData)
|
||||
{
|
||||
var binaryNoInternOption = AcBinarySerializerOptions.Default;
|
||||
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
|
||||
|
||||
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
|
||||
binaryDefaultNoSgenOption.UseGeneratedCode = false;
|
||||
|
||||
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeNoSgenOption.UseGeneratedCode = false;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
|
||||
// AcBinary variants
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
|
||||
////new AcBinaryBenchmark(testData.Order, binaryFastModeNoSgenOption, SerializerAcBinaryFastNoSgen),
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
|
||||
////new AcBinaryBenchmark(testData.Order, binaryDefaultNoSgenOption, SerializerAcBinaryDefaultNoSgen),
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
|
||||
//new AcBinaryBenchmark(testData.Order, binaryNoInternOption, SerializerAcBinaryNoIntern),
|
||||
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastNoSgen),
|
||||
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefaultNoSgen),
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
|
||||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
|
||||
|
||||
|
||||
// MemoryPack
|
||||
new MemoryPackBenchmark(testData.Order, SerializerMemoryPack),
|
||||
|
||||
// MessagePack
|
||||
new MessagePackBenchmark(testData.Order, SerializerMessagePack),
|
||||
|
||||
// AcBinary BufferWriter
|
||||
//new AcBinaryBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryBufferWriter),
|
||||
|
||||
// System.Text.Json
|
||||
//new SystemTextJsonBenchmark(testData.Order, SerializerSystemTextJson)
|
||||
};
|
||||
}
|
||||
|
||||
private static double RunTimed(Action action, int iterations)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
action();
|
||||
}
|
||||
sw.Stop();
|
||||
return sw.Elapsed.TotalMilliseconds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serializer Implementations
|
||||
|
||||
private interface ISerializerBenchmark
|
||||
{
|
||||
string Name { get; }
|
||||
int SerializedSize { get; }
|
||||
void Warmup(int iterations);
|
||||
void Serialize();
|
||||
void Deserialize();
|
||||
}
|
||||
|
||||
private sealed class AcBinaryBenchmark : ISerializerBenchmark
|
||||
{
|
||||
private readonly TestOrder _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public string Name { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
|
||||
public AcBinaryBenchmark(TestOrder order, AcBinarySerializerOptions options, string name)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
Name = name;
|
||||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||||
|
||||
//_options.UseCompression = Lz4CompressionMode.Block;
|
||||
}
|
||||
|
||||
public void Warmup(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
Serialize();
|
||||
Deserialize();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
//if (_options.ReferenceHandling != ReferenceHandlingMode.None || _options.UseStringInterning != StringInterningMode.None)
|
||||
//{
|
||||
// AcBinarySerializer.ScanOnly(_order, _options);
|
||||
//}
|
||||
//else AcBinarySerializer.Serialize(_order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
}
|
||||
|
||||
private sealed class MemoryPackBenchmark : ISerializerBenchmark
|
||||
{
|
||||
private readonly TestOrder _order;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public string Name { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
|
||||
public MemoryPackBenchmark(TestOrder order, string name)
|
||||
{
|
||||
_order = order;
|
||||
Name = name;
|
||||
_serialized = MemoryPackSerializer.Serialize(order);
|
||||
}
|
||||
|
||||
public void Warmup(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
Serialize();
|
||||
Deserialize();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => MemoryPackSerializer.Serialize(_order);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
|
||||
}
|
||||
|
||||
private sealed class MessagePackBenchmark : ISerializerBenchmark
|
||||
{
|
||||
private readonly TestOrder _order;
|
||||
private readonly MessagePackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public string Name { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
|
||||
public MessagePackBenchmark(TestOrder order, string name)
|
||||
{
|
||||
_order = order;
|
||||
Name = name;
|
||||
|
||||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
|
||||
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
|
||||
|
||||
_serialized = MessagePackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
public void Warmup(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
Serialize();
|
||||
Deserialize();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MessagePackSerializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
}
|
||||
|
||||
private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark
|
||||
{
|
||||
private readonly TestOrder _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
private ArrayBufferWriter<byte> _bufferWriter;
|
||||
|
||||
public string Name { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
|
||||
public AcBinaryBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string name)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
Name = name;
|
||||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||||
//_bufferWriter = new ArrayBufferWriter<byte>();
|
||||
}
|
||||
|
||||
public void Warmup(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
Serialize();
|
||||
Deserialize();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
//_bufferWriter.ResetWrittenCount();
|
||||
_bufferWriter = new ArrayBufferWriter<byte>();
|
||||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
}
|
||||
|
||||
private sealed class SystemTextJsonBenchmark : ISerializerBenchmark
|
||||
{
|
||||
private readonly TestOrder _order;
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private readonly string _serialized;
|
||||
private readonly byte[] _serializedUtf8;
|
||||
|
||||
public string Name { get; }
|
||||
public int SerializedSize => _serializedUtf8.Length;
|
||||
|
||||
public SystemTextJsonBenchmark(TestOrder order, string name)
|
||||
{
|
||||
_order = order;
|
||||
Name = name;
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
_serialized = System.Text.Json.JsonSerializer.Serialize(order, _options);
|
||||
_serializedUtf8 = Utf8NoBom.GetBytes(_serialized);
|
||||
}
|
||||
|
||||
public void Warmup(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
Serialize();
|
||||
Deserialize();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => System.Text.Json.JsonSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => System.Text.Json.JsonSerializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Results
|
||||
|
||||
private sealed class BenchmarkResult
|
||||
{
|
||||
public string TestDataName { get; set; } = "";
|
||||
public string SerializerName { get; set; } = "";
|
||||
public int SerializedSize { get; set; }
|
||||
public double SerializeTimeMs { get; set; }
|
||||
public double DeserializeTimeMs { get; set; }
|
||||
public double RoundTripTimeMs => SerializeTimeMs + DeserializeTimeMs;
|
||||
}
|
||||
|
||||
private static void PrintResult(BenchmarkResult result)
|
||||
{
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs,8:F2} ms" : " N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs,8:F2} ms" : " N/A";
|
||||
System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} | Des: {des}");
|
||||
}
|
||||
|
||||
private static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
System.Console.WriteLine("\n");
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
|
||||
var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack);
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
|
||||
|
||||
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(98, '─') + "┐");
|
||||
System.Console.WriteLine($"│ {"#",-4} │ {"Serializer",-25} │ {"Size",-10} │ {"Serialize",-12} │ {"Deserialize",-12} │ {"Round-trip",-12} │");
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(27, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┤");
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
|
||||
|
||||
// Highlight MessagePack and AcBinary (Default) with win/lose colors
|
||||
var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault;
|
||||
var prefix = isHighlighted ? "│►" : "│ ";
|
||||
var suffix = isHighlighted ? "◄│" : " │";
|
||||
|
||||
// Color logic: Green = winner (faster), Red = loser (slower)
|
||||
if (isHighlighted && msgPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var isMsgPack = result.SerializerName == SerializerMessagePack;
|
||||
var msgPackFaster = msgPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
|
||||
|
||||
if (isMsgPack)
|
||||
{
|
||||
System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
}
|
||||
}
|
||||
|
||||
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.SerializerName,-25} │ {size,10} │ {ser,12} │ {des,12} │ {rt,12}{suffix}");
|
||||
|
||||
if (isHighlighted)
|
||||
{
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Footer row: AcBinary (Default) vs MessagePack comparison per column
|
||||
if (msgPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||||
var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||||
var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||||
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(27, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(13, '─')}┤");
|
||||
System.Console.Write($"│ ► Default vs {SerializerMessagePack,-19} │ ");
|
||||
|
||||
// Size
|
||||
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{sizePct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Serialize
|
||||
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{serPct,+11:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Deserialize
|
||||
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{desPct,+11:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Round-trip
|
||||
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{rtPct,+10:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine(" │");
|
||||
}
|
||||
|
||||
System.Console.WriteLine($"└{"─".PadRight(6, '─')}─{"─".PadRight(27, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(14, '─')}┴{"─".PadRight(14, '─')}┴{"─".PadRight(13, '─')}┘");
|
||||
//System.Console.WriteLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
|
||||
//System.Console.WriteLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
|
||||
}
|
||||
|
||||
// Summary: Best serializer for each category
|
||||
System.Console.WriteLine("\n");
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-25} │ {"Avg Value",-18}");
|
||||
System.Console.WriteLine($"{"─".PadRight(20, '─')}─┼─{"─".PadRight(25, '─')}─┼─{"─".PadRight(18, '─')}");
|
||||
|
||||
// Fastest Serialize
|
||||
var fastestSer = results.Where(r => r.SerializeTimeMs > 0)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.SerializeTimeMs) })
|
||||
.OrderBy(x => x.AvgTime)
|
||||
.FirstOrDefault();
|
||||
if (fastestSer != null)
|
||||
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-25} │ {fastestSer.AvgTime,15:F2} ms");
|
||||
|
||||
// Fastest Deserialize
|
||||
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.DeserializeTimeMs) })
|
||||
.OrderBy(x => x.AvgTime)
|
||||
.FirstOrDefault();
|
||||
if (fastestDes != null)
|
||||
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-25} │ {fastestDes.AvgTime,15:F2} ms");
|
||||
|
||||
// Smallest Size
|
||||
var smallestSize = results
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
|
||||
.OrderBy(x => x.AvgSize)
|
||||
.FirstOrDefault();
|
||||
if (smallestSize != null)
|
||||
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-25} │ {smallestSize.AvgSize,15:F0} B");
|
||||
|
||||
// Fastest Round-trip
|
||||
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.RoundTripTimeMs) })
|
||||
.OrderBy(x => x.AvgTime)
|
||||
.FirstOrDefault();
|
||||
if (fastestRt != null)
|
||||
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-25} │ {fastestRt.AvgTime,15:F2} ms");
|
||||
|
||||
// Overall AcBinary Default vs MessagePack comparison
|
||||
var msgPackSerResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList();
|
||||
var msgPackDesResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList();
|
||||
var msgPackRtResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
// Skip comparison if no data available
|
||||
if (msgPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──");
|
||||
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
|
||||
return;
|
||||
System.Console.WriteLine("─────────────────────────────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Returning to menu — press any key to continue, or Q to quit...");
|
||||
var key = System.Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Q) return;
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
|
||||
var msgPackAvgSer = msgPackSerResults.Count > 0 ? msgPackSerResults.Average(r => r.SerializeTimeMs) : 0;
|
||||
var msgPackAvgDes = msgPackDesResults.Average(r => r.DeserializeTimeMs);
|
||||
var msgPackAvgRt = msgPackRtResults.Average(r => r.RoundTripTimeMs);
|
||||
var msgPackAvgSize = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize);
|
||||
|
||||
var acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeTimeMs) : 0;
|
||||
var acBinaryAvgDes = acBinaryDesResults.Average(r => r.DeserializeTimeMs);
|
||||
var acBinaryAvgRt = acBinaryRtResults.Average(r => r.RoundTripTimeMs);
|
||||
var acBinaryAvgSize = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──");
|
||||
|
||||
// Only show serialize comparison if data available
|
||||
if (msgPackAvgSer > 0 && acBinaryAvgSer > 0)
|
||||
{
|
||||
var serPctAll = (acBinaryAvgSer / msgPackAvgSer - 1) * 100;
|
||||
System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {msgPackAvgSer:F2} ms)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
var desPctAll = (acBinaryAvgDes / msgPackAvgDes - 1) * 100;
|
||||
var rtPctAll = (acBinaryAvgRt / msgPackAvgRt - 1) * 100;
|
||||
var sizePctAll = (acBinaryAvgSize / msgPackAvgSize - 1) * 100;
|
||||
|
||||
System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {msgPackAvgDes:F2} ms)");
|
||||
System.Console.ResetColor();
|
||||
|
||||
System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {msgPackAvgRt:F2} ms)");
|
||||
System.Console.ResetColor();
|
||||
|
||||
System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {msgPackAvgSize:F0} B)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
Directory.CreateDirectory(ResultsDirectory);
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||
var baseFileName = $"Console.FullBenchmark_{BuildConfiguration}_{timestamp}";
|
||||
var logFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.log");
|
||||
var outputFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.output");
|
||||
|
||||
// Save binary output to separate .output file
|
||||
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large"));
|
||||
if (largeTestData != null)
|
||||
{
|
||||
var outputSb = new StringBuilder();
|
||||
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
|
||||
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||||
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
outputSb.AppendLine();
|
||||
|
||||
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
|
||||
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
|
||||
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
|
||||
outputSb.AppendLine();
|
||||
outputSb.AppendLine("Hex dump:");
|
||||
outputSb.AppendLine(FormatHexDump(serializedBytes));
|
||||
|
||||
File.WriteAllText(outputFilePath, outputSb.ToString(), Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
|
||||
}
|
||||
|
||||
// Save benchmark results to .log file
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
|
||||
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Build: {BuildConfiguration}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Iterations: {TestIterations}".PadRight(100) + "║");
|
||||
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
sb.AppendLine();
|
||||
|
||||
// CSV-like data for easy import
|
||||
sb.AppendLine("=== RAW DATA (CSV) ===");
|
||||
sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs");
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Formatted results
|
||||
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
|
||||
sb.AppendLine($"(►) = Highlighted: {SerializerMessagePack} (baseline) and {SerializerAcBinaryDefault}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
|
||||
var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack);
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"--- {testData.DisplayName} ---");
|
||||
sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14}");
|
||||
sb.AppendLine(new string('-', 86));
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault;
|
||||
var prefix = isHighlighted ? "► " : " ";
|
||||
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
|
||||
|
||||
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14}");
|
||||
}
|
||||
|
||||
// Summary row for this test data
|
||||
if (msgPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||||
var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||||
var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||||
|
||||
sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMessagePack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
|
||||
}
|
||||
|
||||
//sb.AppendLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
|
||||
//sb.AppendLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
|
||||
}
|
||||
|
||||
|
||||
// Summary comparison
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ===");
|
||||
|
||||
var msgPackSerResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList();
|
||||
var msgPackDesResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList();
|
||||
var msgPackRtResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
if (msgPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
|
||||
{
|
||||
var msgPackAvgSer2 = msgPackSerResults2.Average(r => r.SerializeTimeMs);
|
||||
var acBinaryAvgSer2 = acBinarySerResults2.Average(r => r.SerializeTimeMs);
|
||||
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / msgPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {msgPackAvgSer2:F2} ms)");
|
||||
}
|
||||
|
||||
if (msgPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
|
||||
{
|
||||
var msgPackAvgDes2 = msgPackDesResults2.Average(r => r.DeserializeTimeMs);
|
||||
var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => r.DeserializeTimeMs);
|
||||
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / msgPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {msgPackAvgDes2:F2} ms)");
|
||||
}
|
||||
|
||||
if (msgPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
|
||||
{
|
||||
var msgPackAvgRt2 = msgPackRtResults2.Average(r => r.RoundTripTimeMs);
|
||||
var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => r.RoundTripTimeMs);
|
||||
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / msgPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {msgPackAvgRt2:F2} ms)");
|
||||
}
|
||||
|
||||
var msgPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize);
|
||||
var acBinaryAvgSize2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
|
||||
sb.AppendLine($" Size: {((acBinaryAvgSize2 / msgPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {msgPackAvgSize2:F0} B)");
|
||||
|
||||
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
|
||||
/// Parses CLI arguments into (layer, opMode, serializerMode) and, as a side effect, the active
|
||||
/// charset (<see cref="BenchmarkTestDataProvider.LongStringSuffix"/>). Each arg is classified
|
||||
/// independently and case-insensitively, so multiple args combine in any order — e.g.
|
||||
/// <c>FastestByte AsciiShort</c> or <c>Serialize Large Latin1Short</c>. Per arg, in order:
|
||||
/// <c>"quick"</c> (mutates <see cref="Configuration"/> warmup/iter counts), <see cref="SerializerSelectionMode"/>,
|
||||
/// <see cref="BenchmarkOpMode"/>, <see cref="BenchmarkLayer"/>, then a charset name
|
||||
/// (see <see cref="TryApplyCharsetArg"/>). Unrecognized args are warned and ignored; dimensions left
|
||||
/// unset keep their defaults (All, All, Standard, and the <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
|
||||
/// field default for charset). Always returns <c>true</c> (kept for caller-side abort symmetry).
|
||||
/// </summary>
|
||||
private static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
|
||||
private static bool TryParseCliArgs(string[] args, out BenchmarkLayer layer, out BenchmarkOpMode opMode, out SerializerSelectionMode serializerMode)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < bytes.Length; i += bytesPerLine)
|
||||
layer = BenchmarkLayer.All;
|
||||
opMode = BenchmarkOpMode.All;
|
||||
serializerMode = SerializerSelectionMode.Standard;
|
||||
|
||||
// Each arg is classified independently → multiple args combine in any order. Without the
|
||||
// charset branch the CLI path never sets the charset, so it silently used the Latin1Long
|
||||
// field default — diverging from interactive runs (where the menu pins it).
|
||||
foreach (var arg in args)
|
||||
{
|
||||
// Offset
|
||||
sb.Append($"{i:X8} ");
|
||||
|
||||
// Hex bytes
|
||||
for (var j = 0; j < bytesPerLine; j++)
|
||||
// Quick mode: short warmup, few iterations, small sample count. Not an enum value — it's a
|
||||
// Configuration meta-flag, so handle it before the enum-parse cascade.
|
||||
if (string.Equals(arg, "quick", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i + j < bytes.Length)
|
||||
sb.Append($"{bytes[i + j]:X2} ");
|
||||
else
|
||||
sb.Append(" ");
|
||||
|
||||
if (j == 7) sb.Append(' '); // Extra space in middle
|
||||
Configuration.WarmupIterations = 5;
|
||||
Configuration.TestIterations = 100;
|
||||
Configuration.BenchmarkSamples = 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(" |");
|
||||
|
||||
// ASCII representation
|
||||
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
|
||||
// Serializer-selection (AsyncPipe/FastestByte/Standard).
|
||||
if (Enum.TryParse<SerializerSelectionMode>(arg, ignoreCase: true, out var sm))
|
||||
{
|
||||
var b = bytes[i + j];
|
||||
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
|
||||
serializerMode = sm;
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.AppendLine("|");
|
||||
// Op-mode (Serialize/Deserialize/All).
|
||||
if (Enum.TryParse<BenchmarkOpMode>(arg, ignoreCase: true, out var om))
|
||||
{
|
||||
opMode = om;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Layer (Core/Comprehensive/Edge/Small/Medium/Large/Repeated/Deep/All).
|
||||
if (Enum.TryParse<BenchmarkLayer>(arg, ignoreCase: true, out var ly))
|
||||
{
|
||||
layer = ly;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Charset (long-string suffix profile) — mirrors the interactive ShowCharsetSettingsMenu.
|
||||
if (TryApplyCharsetArg(arg))
|
||||
continue;
|
||||
|
||||
// Unknown arg — ignored, defaults stand. Matches prior unrecognized-arg leniency.
|
||||
System.Console.Error.WriteLine($"Warning: unrecognized argument '{arg}'. Ignored (defaults: Layer=All, OpMode=All, SerializerMode=Standard, charset unchanged).");
|
||||
}
|
||||
return sb.ToString();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
/// <summary>
|
||||
/// Maps a case-insensitive charset name to its <see cref="CharsetSuffixes"/> value and assigns
|
||||
/// <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>. Names mirror the interactive
|
||||
/// <c>ShowCharsetSettingsMenu</c> options. <see cref="CharsetSuffixes"/> members are <c>const string</c>,
|
||||
/// so this is a name→value match rather than an <see cref="Enum.TryParse{T}(string, bool, out T)"/>.
|
||||
/// Returns <c>false</c> when the name is not a known charset (the caller then treats the arg as unknown).
|
||||
/// </summary>
|
||||
private static bool TryApplyCharsetArg(string arg)
|
||||
{
|
||||
string? suffix = arg.ToLowerInvariant() switch
|
||||
{
|
||||
"asciifix" => CharsetSuffixes.AsciiFix,
|
||||
"asciishort" => CharsetSuffixes.AsciiShort,
|
||||
"asciilong" => CharsetSuffixes.AsciiLong,
|
||||
"latin1fix" => CharsetSuffixes.Latin1Fix,
|
||||
"latin1short" => CharsetSuffixes.Latin1Short,
|
||||
"latin1long" => CharsetSuffixes.Latin1Long,
|
||||
"cjkbmpshort" => CharsetSuffixes.CjkBmpShort,
|
||||
"cjkbmplong" => CharsetSuffixes.CjkBmpLong,
|
||||
"cyrillicshort" => CharsetSuffixes.CyrillicShort,
|
||||
"cyrilliclong" => CharsetSuffixes.CyrillicLong,
|
||||
"mixedshort" => CharsetSuffixes.MixedShort,
|
||||
"mixedlong" => CharsetSuffixes.MixedLong,
|
||||
_ => null
|
||||
};
|
||||
if (suffix is null)
|
||||
return false;
|
||||
BenchmarkTestDataProvider.LongStringSuffix = suffix;
|
||||
return true;
|
||||
}
|
||||
|
||||
// RunBenchmark + RunBenchmarksForTestData + CreateSerializers → BenchmarkLoop.cs
|
||||
|
||||
// Serializer implementations (ISerializerBenchmark + 12 concrete benchmark classes) → Benchmarks/
|
||||
|
||||
// Results / output formatters → Output.cs
|
||||
// BenchmarkResult DTO → BenchmarkResult.cs
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,89 @@
|
|||
# AyCode.Core.Serializers.Console
|
||||
|
||||
Standalone benchmark console application for comparing serializer performance. Targets .NET 9. Measures serialize/deserialize speed, output size, and compression across multiple serializers and data shapes.
|
||||
Interactive console runner for the serializer benchmark suite. Targets .NET 9.
|
||||
|
||||
## Compared Serializers
|
||||
> **Companion**: shares its workload + reporting infrastructure with the BDN runner in [`AyCode.Benchmark/`](../AyCode.Benchmark/README.md) via `<ProjectReference>`. See that project's README for the full dual-runner architecture.
|
||||
|
||||
- **AcBinary** — Multiple configurations: Default, NoRef, FastMode, NoIntern, with/without source generation
|
||||
- **MessagePack**
|
||||
- **MemoryPack**
|
||||
## Role
|
||||
|
||||
(System.Text.Json and Newtonsoft.Json comparisons exist but are currently commented out.)
|
||||
This is the **fast-iteration** half of the benchmark stack — a custom adaptive measure engine optimized for short turnaround (~1-3 min full run) during micro-optimization loops. The BDN half lives in `AyCode.Benchmark` and produces statistically tighter numbers (~5-15 min full run) for before-commit validation. Both runners emit the **same** `.log` / `.LLM` / `.output` triplet to `Test_Benchmark_Results/Benchmark/` — Console prefixes with `Console.`, BDN with `Bdn.`.
|
||||
|
||||
## Key Files
|
||||
## Compared serializers
|
||||
|
||||
- **`Program.cs`** — Benchmark runner. Modes: `all` (default), `quick` (fewer iterations), `serialize`, `deserialize`, `profiler` (memory profiler warmup). Outputs results to `Test_Benchmark_Results/Benchmark/`. Iterations: 5000 warmup + 1000 test (Release), 0+1 (Debug).
|
||||
- **`BenchmarkTestDataProvider.cs`** — Test data factory producing 5 data shapes:
|
||||
- Small (2x2x2x2), Medium (3x3x3x4), Large (5x5x5x10)
|
||||
- Repeated Strings (10 items, string deduplication testing)
|
||||
- Deep Nested (2x4x4x8, depth stress test)
|
||||
- Uses `TestOrder` model from `AyCode.Core.Tests` with configurable IId reference percentages.
|
||||
- **AcBinary** — multiple options presets: `FastMode` (Compact wire, no ref handling, no interning), `Default` (with ref handling + interning), plus SGen / Runtime dispatch variants and Compact / Fast wire modes.
|
||||
- **MemoryPack** — SOTA baseline, wire-mode-aligned with AcBinary for apples-to-apples encoding comparison (UTF-8 ↔ Compact, UTF-16 ↔ Fast).
|
||||
- **MessagePack** — JIT-only (AOT incompatible due to dynamic resolver).
|
||||
- **System.Text.Json** — reference comparison (commented out in `CreateSerializers` by default).
|
||||
|
||||
## Key files
|
||||
|
||||
- [`Program.cs`](Program.cs) — entry point. Parses CLI args (`Core` / `Comprehensive` / `Edge` / per-cell / op-mode / serializer-set) or falls into interactive `Menu`.
|
||||
- [`Menu.cs`](Menu.cs) — interactive layer/serializer-set selection + nested settings (iteration counts, wire mode, charset).
|
||||
- [`BenchmarkLoop.cs`](BenchmarkLoop.cs) — custom adaptive measure engine. CPU 0 affinity pin + High priority for stabilization, JIT pre-warmup, phase-isolated Ser/Des warmup→measure with `GC.Collect` at every boundary, 10-sample median + pilot discard, adaptive iter calibration to ~250ms/cell wall-clock, dedicated allocation-only sample.
|
||||
- [`Configuration.cs`](Configuration.cs) — Console-side state (`SelectedWireMode`, `WarmupIterations`, `BenchmarkSamples`, `TargetSampleMs`, charset selection, `BuildConfiguration` const from `#if DEBUG/RELEASE/AYCODE_NATIVEAOT`).
|
||||
|
||||
Workload + reporting types — `ISerializerBenchmark`, `BenchmarkResult`, `BenchmarkOptions`, `BenchmarkEnums`, `BenchmarkReportWriter`, `ReportingContext`, the 12 concrete `*Benchmark<T>` classes (`AcBinaryBenchmark`, `MemoryPackBenchmark`, `AcBinaryBufferWriterBenchmark`, ...), `RoundTripValidator` — live in [`AyCode.Benchmark/Workloads/Scenarios/`](../AyCode.Benchmark/Workloads/Scenarios/) and [`AyCode.Benchmark/Reporting/`](../AyCode.Benchmark/Reporting/).
|
||||
|
||||
## Test data
|
||||
|
||||
5 cells, provided by `AyCode.Core.Tests.TestModels.BenchmarkTestDataProvider*`:
|
||||
|
||||
- **Small** (2×2×2×2)
|
||||
- **Medium** (3×3×3×4)
|
||||
- **Large** (5×5×5×10)
|
||||
- **Repeated Strings** (10 items, string-deduplication stress)
|
||||
- **Deep Nested** (2×4×4×8, depth stress)
|
||||
|
||||
20% IId reference rate by default. Two graph variants (`TestOrder_All_False` / `_All_True`) are built per cell — AcBinary's option preset picks which variant gets fed to it (`UsesAllFalseVariant` rule in `BenchmarkLoop`).
|
||||
|
||||
## Charset profiles (Menu → Settings → Charset)
|
||||
|
||||
Controls the `BenchmarkTestDataProvider.LongStringSuffix` — the string-tail appended to property values. Influences string-marker selection on the wire (FixStrAscii vs StringSmall / Medium / Big / StringAscii), interning hit rates, and UTF-8 encode cost.
|
||||
|
||||
**Consistent length across all charsets** (UTF-16 char count): every `*Short` = 40 char, every `*Long` = 280 char (= Short × 7). Isolates the workload variable to UTF-8 byte content per charset (1-byte ASCII vs 2-byte Latin1 / Cyrillic vs 3-byte CJK vs mixed) — wire-size and encode/decode cost differences are pure charset effects, not length effects.
|
||||
|
||||
| Profile | UTF-16 char | UTF-8 byte (approx) | Tier |
|
||||
|---|---|---|---|
|
||||
| `Latin1FixAscii` | 0 | 0 | FixStrAscii / FixStr-equivalent (baseline-only) |
|
||||
| `AsciiShort` | 40 | 40 | StringAscii (167) |
|
||||
| `AsciiLong` | 280 | 280 | StringAscii (167) |
|
||||
| `Latin1Short` | 40 | ~72 | StringSmall (91) |
|
||||
| `Latin1Long` (**default**) | 280 | ~504 | StringMedium (94) |
|
||||
| `CjkBmpShort` | 40 | ~104 | StringSmall |
|
||||
| `CjkBmpLong` | 280 | ~728 | StringMedium |
|
||||
| `CyrillicShort` | 40 | ~72 | StringSmall |
|
||||
| `CyrillicLong` | 280 | ~504 | StringMedium |
|
||||
| `MixedShort` | 40 | ~88 | StringSmall |
|
||||
| `MixedLong` | 280 | ~616 | StringMedium |
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
dotnet run -c Release --project AyCode.Core.Serializers.Console -- [arg]
|
||||
```
|
||||
|
||||
| Arg | Result |
|
||||
|---|---|
|
||||
| _(no args)_ | Interactive menu — pick layer (Core / Comprehensive / Edge / Small / Medium / Large / Repeated / Deep / All) × serializer-set (Standard / FastestByte ["F"] / AsyncPipe ["P"]). |
|
||||
| `Core` / `Comprehensive` / `Edge` / `Small` / `Medium` / `Large` / `Repeated` / `Deep` / `All` | Run that layer at `Standard` serializer-set, `All` op-mode. |
|
||||
| `FastestByte` / `AsyncPipe` / `Standard` | Run that serializer-set, `All` layer, `All` op-mode. |
|
||||
| `Serialize` / `Deserialize` / `All` | Run that op-mode, `All` layer, `Standard` serializer-set. |
|
||||
| `quick` | Single-sample fast mode (Debug-equivalent — very loose numbers, smoke-test only). |
|
||||
|
||||
Output: `Test_Benchmark_Results/Benchmark/Console.FullBenchmark_<Build>_<timestamp>.{log,LLM,output}`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `AyCode.Core` | Core library with AcBinary serializer |
|
||||
| `AyCode.Core.Tests` | Test models (`TestOrder`, `TestDataFactory`, etc.) |
|
||||
| `MemoryPack` | Competitor benchmark |
|
||||
| `MessagePack` | Competitor benchmark |
|
||||
| `Newtonsoft.Json` | Competitor benchmark |
|
||||
| `AyCode.Core` (ProjectReference) | AcBinary serializer |
|
||||
| `AyCode.Core.Tests` (ProjectReference) | Test data factory + test models |
|
||||
| `AyCode.Benchmark` (ProjectReference) | Shared workload + reporting (`ISerializerBenchmark`, `BenchmarkResult`, `BenchmarkReportWriter`, `ReportingContext`, the 12 concrete benchmark classes) |
|
||||
| `MemoryPack` | Comparison target (also via Workloads) |
|
||||
| `MessagePack` | Comparison target |
|
||||
| `Newtonsoft.Json` | Comparison target (currently disabled) |
|
||||
|
||||
---
|
||||
## Build & publish notes
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
- `<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>` in the csproj explicitly disambiguates the entry point — necessary because this Exe references another Exe (`AyCode.Benchmark`), and the build would otherwise complain about multiple `Main` methods.
|
||||
- AOT publish (`dotnet publish -c Release`) is configured via `'$(_IsPublishing)' == 'true'` PropertyGroup. The Benchmark project's BDN-stack (BenchmarkDotNet, Iced disassembler, MongoDB.Bson) is pulled in transitively — accepted tradeoff for the unified workload sharing.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
using System.Collections.Generic;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Build-time diagnostics for the AcBinary source generator.
|
||||
///
|
||||
/// <para><b>Registered diagnostics</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ACBIN001</c> — <see cref="CircularReferenceWarning"/>: detects circular type references
|
||||
/// among <c>[AcBinarySerializable]</c> types and warns the developer to consider ref-handling mode.</item>
|
||||
/// <item><c>ACBIN002</c> — <see cref="PolymorphicPropertyWithFeatureDisabledError"/>: ACCORE-BIN-I-T7K3
|
||||
/// compile-time guard. Fires when a type opts out of <c>EnablePolymorphDetectFeature</c> AND still
|
||||
/// declares an <c>object</c> property — the SGen-emitted writer would silently corrupt the wire.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
private static readonly DiagnosticDescriptor CircularReferenceWarning = new(
|
||||
id: "ACBIN001",
|
||||
title: "Circular reference detected",
|
||||
messageFormat: "Type '{0}' participates in a circular reference chain: {1}. Consider using ReferenceHandling.OnlyId or .All to avoid exponential serialization size.",
|
||||
category: "AcBinarySerializer",
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
/// <summary>
|
||||
/// ACCORE-BIN-I-T7K3 compile-time guard: a property declared as <c>System.Object</c> requires
|
||||
/// polymorphic-prefix emit (<c>ObjectWithTypeName</c>) so the deserializer can resolve the
|
||||
/// concrete runtime type. When the type opts out of the feature via
|
||||
/// <c>[AcBinarySerializable(enablePolymorphDetectFeature: false)]</c>, the prefix is suppressed
|
||||
/// and the wire silently corrupts on round-trip (FixObj slot byte against <c>typeof(object)</c>
|
||||
/// at read-time → 0-byte object wrapper → reader position drifts → downstream
|
||||
/// <c>DECIMAL_DRIFT</c> / <c>IndexOutOfRangeException</c>).
|
||||
///
|
||||
/// Surface the misconfiguration at build time so the silent corruption is structurally
|
||||
/// impossible. Three escape hatches for the developer:
|
||||
/// 1. Enable the polymorph-detect feature on the type
|
||||
/// (<c>[AcBinarySerializable(...enablePolymorphDetectFeature: true)]</c> — default true).
|
||||
/// 2. Change the property type to a concrete type (no polymorphism needed).
|
||||
/// 3. Mark the property with <c>[AcBinaryIgnore]</c> — ignored properties are filtered out
|
||||
/// at property enumeration, so this diagnostic does not fire for them.
|
||||
/// </summary>
|
||||
private static readonly DiagnosticDescriptor PolymorphicPropertyWithFeatureDisabledError = new(
|
||||
id: "ACBIN002",
|
||||
title: "Polymorphic property requires EnablePolymorphDetectFeature",
|
||||
messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but EnablePolymorphDetectFeature is disabled on the type. " +
|
||||
"The generated writer would silently corrupt the wire on round-trip. " +
|
||||
"To fix: (1) enable EnablePolymorphDetectFeature on [AcBinarySerializable], (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].",
|
||||
category: "AcBinarySerializer",
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
/// <summary>
|
||||
/// ACCORE-BIN-I-T7K3 guard: emits <see cref="PolymorphicPropertyWithFeatureDisabledError"/>
|
||||
/// (ACBIN002) for every <c>System.Object</c>-declared property on any
|
||||
/// <c>[AcBinarySerializable]</c> type whose <c>EnablePolymorphDetectFeature</c> is <c>false</c>.
|
||||
/// Per-class gating: types with the feature enabled (default) skip the check entirely; only
|
||||
/// opt-out types are scanned for misuse.
|
||||
/// </summary>
|
||||
private static void DetectAndReportPolymorphicMisuse(List<SerializableClassInfo> classes, SourceProductionContext spc)
|
||||
{
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
if (ci.EnablePolymorphDetect) continue; // Feature enabled → polymorphic prefix is emitted, no misuse possible.
|
||||
|
||||
foreach (var p in ci.Properties)
|
||||
{
|
||||
if (p.IsObjectDeclaredType)
|
||||
{
|
||||
spc.ReportDiagnostic(Diagnostic.Create(
|
||||
PolymorphicPropertyWithFeatureDisabledError, Location.None,
|
||||
ci.ClassName, p.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects circular reference chains among [AcBinarySerializable] types at compile time
|
||||
/// and reports ACBIN001 warnings. Uses DFS with 3-color marking to find back-edges.
|
||||
/// </summary>
|
||||
private static void DetectAndReportCycles(List<SerializableClassInfo> classes, SourceProductionContext spc)
|
||||
{
|
||||
// Build lookup: WriterClassName → FullTypeName
|
||||
var writerToFull = new Dictionary<string, string>(classes.Count);
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
var writerName = string.IsNullOrEmpty(ci.Namespace)
|
||||
? $"{ci.ClassName}_GeneratedWriter"
|
||||
: $"{ci.Namespace}.{ci.ClassName}_GeneratedWriter";
|
||||
writerToFull[writerName] = ci.FullTypeName;
|
||||
}
|
||||
|
||||
// Build adjacency list: FullTypeName → set of referenced FullTypeNames
|
||||
var adjacency = new Dictionary<string, HashSet<string>>(classes.Count);
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
var edges = new HashSet<string>();
|
||||
foreach (var p in ci.Properties)
|
||||
{
|
||||
if (p.TypeKind == PropertyTypeKind.Complex && p.HasGeneratedWriter && p.WriterClassName != null)
|
||||
{
|
||||
if (writerToFull.TryGetValue(p.WriterClassName, out var target))
|
||||
edges.Add(target);
|
||||
}
|
||||
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter && p.ElementWriterClassName != null)
|
||||
{
|
||||
if (writerToFull.TryGetValue(p.ElementWriterClassName, out var target))
|
||||
edges.Add(target);
|
||||
}
|
||||
if (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter && p.DictValueWriterClassName != null)
|
||||
{
|
||||
if (writerToFull.TryGetValue(p.DictValueWriterClassName, out var target))
|
||||
edges.Add(target);
|
||||
}
|
||||
}
|
||||
adjacency[ci.FullTypeName] = edges;
|
||||
}
|
||||
|
||||
// DFS with 3-color marking: White=0, Gray=1, Black=2
|
||||
var color = new Dictionary<string, int>(classes.Count);
|
||||
foreach (var ci in classes)
|
||||
color[ci.FullTypeName] = 0;
|
||||
|
||||
var stack = new List<string>();
|
||||
var reported = new HashSet<string>();
|
||||
|
||||
void Dfs(string node)
|
||||
{
|
||||
color[node] = 1; // Gray
|
||||
stack.Add(node);
|
||||
|
||||
if (adjacency.TryGetValue(node, out var neighbors))
|
||||
{
|
||||
foreach (var next in neighbors)
|
||||
{
|
||||
if (!color.TryGetValue(next, out var c)) continue;
|
||||
if (c == 1) // Gray → back-edge = cycle
|
||||
{
|
||||
var cycleStart = stack.IndexOf(next);
|
||||
var parts = new List<string>();
|
||||
for (var i = cycleStart; i < stack.Count; i++)
|
||||
parts.Add(ShortTypeName(stack[i]));
|
||||
parts.Add(ShortTypeName(next)); // close the cycle
|
||||
|
||||
var cycleDesc = string.Join(" → ", parts);
|
||||
for (var i = cycleStart; i < stack.Count; i++)
|
||||
{
|
||||
if (reported.Add(stack[i]))
|
||||
{
|
||||
spc.ReportDiagnostic(Diagnostic.Create(
|
||||
CircularReferenceWarning, Location.None,
|
||||
ShortTypeName(stack[i]), cycleDesc));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (c == 0) // White → unvisited
|
||||
{
|
||||
Dfs(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
color[node] = 2; // Black
|
||||
}
|
||||
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
if (color[ci.FullTypeName] == 0)
|
||||
Dfs(ci.FullTypeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string fullTypeName)
|
||||
{
|
||||
var dot = fullTypeName.LastIndexOf('.');
|
||||
return dot >= 0 ? fullTypeName.Substring(dot + 1) : fullTypeName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Module-init emit pass: generates the static class with a <c>[ModuleInitializer]</c> method that
|
||||
/// auto-registers every generated writer / reader instance into the runtime registries
|
||||
/// (<c>AcBinarySerializer.RegisterGeneratedWriter</c> / <c>AcBinaryDeserializer.RegisterGeneratedReader</c>).
|
||||
/// Emitted once per compilation as <c>AcBinaryGeneratedWriters_Init.g.cs</c>.
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
private static string GenInit(List<SerializableClassInfo> classes)
|
||||
{
|
||||
var sb = new StringBuilder(512);
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("using System.Runtime.CompilerServices;");
|
||||
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace AyCode.Core.Serializers.Generated;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("internal static class AcBinaryGeneratedWritersInit");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" [ModuleInitializer]");
|
||||
sb.AppendLine(" internal static void Register()");
|
||||
sb.AppendLine(" {");
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
var writerRef = string.IsNullOrEmpty(ci.Namespace)
|
||||
? $"{ci.ClassName}_GeneratedWriter"
|
||||
: $"{ci.Namespace}.{ci.ClassName}_GeneratedWriter";
|
||||
var readerRef = string.IsNullOrEmpty(ci.Namespace)
|
||||
? $"{ci.ClassName}_GeneratedReader"
|
||||
: $"{ci.Namespace}.{ci.ClassName}_GeneratedReader";
|
||||
sb.AppendLine($" AcBinarySerializer.RegisterGeneratedWriter(typeof({ci.FullTypeName}), {writerRef}.Instance);");
|
||||
sb.AppendLine($" AcBinaryDeserializer.RegisterGeneratedReader(typeof({ci.FullTypeName}), {readerRef}.Instance);");
|
||||
}
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,909 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side emit pass: generates the <c>IGeneratedBinaryReader</c> implementation for each
|
||||
/// <c>[AcBinarySerializable]</c> type. Emits <c>ReadProperties</c> (inline property reads with marker
|
||||
/// dispatch) and <c>ReadObject</c> (entry point with cache-index registration).
|
||||
///
|
||||
/// <para>Sub-passes:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>EmitReadProp</c> — per-property read emit (markerless + markered variants).</item>
|
||||
/// <item><c>EmitReadString</c> — H2Q6 string-tier marker dispatch (FixStrAscii + tier-tables +
|
||||
/// intern cases gated by <c>EnableInternStringFeature</c>).</item>
|
||||
/// <item><c>EmitReadComplex</c> — Object / ObjectRef* / FixObj-slot dispatch for IId-typed children.</item>
|
||||
/// <item><c>EmitReadCollection</c> / <c>EmitReadCollectionInline</c> / <c>EmitReadCollectionElement</c> /
|
||||
/// <c>EmitReadNonComplexCollectionElement</c> — collection-shape inline reading.</item>
|
||||
/// <item><c>EmitReadDictionary</c> / <c>EmitReadDictElement</c> — dict-shape inline reading.</item>
|
||||
/// <item><c>EmitReadMarkeredValue</c> / <c>EmitReadMarkeredValueForKind</c> — primitive value-with-marker reads.</item>
|
||||
/// <item><c>EmitReadMarkerless</c> — markerless primitive reads (FastMode + per-property markerless types).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
#region Reader Code Generation
|
||||
|
||||
/// <summary>
|
||||
/// Generates the IGeneratedBinaryReader implementation for a type.
|
||||
/// Phase 1: handles markerless path (no UseMetadata). UseMetadata/ChainMode → runtime fallback.
|
||||
/// Eliminates: GetWrapper dictionary lookup, CreateInstance delegate, property setter delegates,
|
||||
/// AccessorType switch dispatch, ReadValue dispatch table.
|
||||
/// </summary>
|
||||
private static string GenReader(SerializableClassInfo ci)
|
||||
{
|
||||
var sb = new StringBuilder(4096);
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("#nullable enable");
|
||||
sb.AppendLine("using System.Runtime.CompilerServices;");
|
||||
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrEmpty(ci.Namespace))
|
||||
sb.AppendLine($"namespace {ci.Namespace};");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"internal sealed class {ci.ClassName}_GeneratedReader : IGeneratedBinaryReader");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedReader Instance = new();");
|
||||
sb.AppendLine();
|
||||
|
||||
// ReadProperties — reads all properties into an existing instance (mirrors WriteProperties)
|
||||
// No depth safety net on deserialize: wire format is linear + finite, the serializer-side counter
|
||||
// already prevents pathological depth in well-formed payloads.
|
||||
sb.AppendLine(" public void ReadProperties<TInput>(object value, AcBinaryDeserializer.BinaryDeserializationContext<TInput> context)");
|
||||
sb.AppendLine(" where TInput : struct, IBinaryInputBase");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);");
|
||||
|
||||
// Emit property reads — markerless for primitive types, markered for the rest
|
||||
foreach (var p in ci.Properties)
|
||||
{
|
||||
sb.AppendLine();
|
||||
EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString);
|
||||
}
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
|
||||
// ReadObject — IGeneratedBinaryReader implementation (delegates to ReadProperties)
|
||||
sb.AppendLine(" public object? ReadObject<TInput>(AcBinaryDeserializer.BinaryDeserializationContext<TInput> context, int cacheIndex)");
|
||||
sb.AppendLine(" where TInput : struct, IBinaryInputBase");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine($" var obj = new {ci.FullTypeName}();");
|
||||
sb.AppendLine(" if (cacheIndex >= 0)");
|
||||
sb.AppendLine(" context.RegisterInternedValueAt(cacheIndex, obj);");
|
||||
sb.AppendLine(" ReadProperties<TInput>(obj, context);");
|
||||
sb.AppendLine(" return obj;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read code for a single property.
|
||||
/// Markerless types: read raw value directly (no type code in stream).
|
||||
/// Markered types: read type code byte, then dispatch.
|
||||
/// Mirrors the serializer's EmitProp symmetry.
|
||||
/// </summary>
|
||||
private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString)
|
||||
{
|
||||
var a = $"obj.{p.Name}";
|
||||
|
||||
// Markerless types: read raw value directly — mirrors EmitMarkerless in writer
|
||||
if (IsMarkerless(p.TypeKind))
|
||||
{
|
||||
if (p.TypeKind == PropertyTypeKind.Enum)
|
||||
sb.AppendLine($"{i}{{ var ev = context.ReadVarInt(); {a} = Unsafe.As<int, {p.TypeNameForTypeof}>(ref ev); }}");
|
||||
else
|
||||
EmitReadMarkerless(sb, p.TypeKind, a, i);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACCORE-BIN-T-K9M3 — caller-driven string marker dispatch. SGen-emit reads the marker byte
|
||||
// locally + handles FastWire on a separate branch; BinaryDeserializationContext.TryReadStringProperty
|
||||
// decodes every non-interning marker (FixStrAscii / StringAscii / StringSmall/Medium/Big / Null /
|
||||
// StringEmpty) in one inlinable body. The 3 interning markers go through TryReadStringColdPath
|
||||
// (AggressiveOptimization, Tier-1 direct). enableInternString gates the `|| TryReadStringColdPath`
|
||||
// emit: interning-enabled types get the short-circuit; non-interning types omit the cold call
|
||||
// entirely — the writer never produces interning markers for them, so TryReadStringProperty alone
|
||||
// is total. PropertySkip / unknown → TryReadStringProperty returns false → property left at
|
||||
// default (don't-touch contract preserved).
|
||||
if (p.TypeKind == PropertyTypeKind.String)
|
||||
{
|
||||
sb.AppendLine($"{i}if (context.FastWire)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} {a} = context.ReadStringUtf16Markerless()!;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var tc_{p.Name} = context.ReadByte();");
|
||||
sb.AppendLine($"{i} string? v_{p.Name};");
|
||||
if (enableInternString)
|
||||
sb.AppendLine($"{i} if (context.TryReadStringProperty(tc_{p.Name}, out v_{p.Name}) || context.TryReadStringColdPath(tc_{p.Name}, out v_{p.Name}))");
|
||||
else
|
||||
sb.AppendLine($"{i} if (context.TryReadStringProperty(tc_{p.Name}, out v_{p.Name}))");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} {a} = v_{p.Name}!;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i}}}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Markered types: read type code, then dispatch
|
||||
var tc = $"tc_{p.Name}";
|
||||
sb.AppendLine($"{i}var {tc} = context.ReadByte();");
|
||||
|
||||
// PropertySkip → leave default
|
||||
sb.AppendLine($"{i}if ({tc} != BinaryTypeCode.PropertySkip)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
|
||||
// Nullable value types
|
||||
if (IsNullableVTKind(p.TypeKind))
|
||||
{
|
||||
sb.AppendLine($"{i} if ({tc} == BinaryTypeCode.Null) {{ /* null */ }}");
|
||||
sb.AppendLine($"{i} else");
|
||||
sb.AppendLine($"{i} {{");
|
||||
EmitReadMarkeredValue(sb, Underlying(p.TypeKind), a, tc, i + " ", p, nullable: true);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (p.TypeKind)
|
||||
{
|
||||
case PropertyTypeKind.String:
|
||||
EmitReadString(sb, a, tc, i + " ", enableInternString);
|
||||
break;
|
||||
|
||||
case PropertyTypeKind.Complex:
|
||||
EmitReadComplex(sb, p, a, tc, i + " ");
|
||||
break;
|
||||
|
||||
case PropertyTypeKind.Collection:
|
||||
EmitReadCollection(sb, p, a, tc, i + " ", enableInternString);
|
||||
break;
|
||||
|
||||
case PropertyTypeKind.Dictionary:
|
||||
EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown markered type (char, sbyte, etc.) — rewind + runtime fallback
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
if (p.IsNullable)
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
|
||||
else
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits raw value read — no type code in stream. Mirrors EmitMarkerless exactly.
|
||||
/// </summary>
|
||||
private static void EmitReadMarkerless(StringBuilder sb, PropertyTypeKind k, string a, string i)
|
||||
{
|
||||
switch (k)
|
||||
{
|
||||
case PropertyTypeKind.Int32: sb.AppendLine($"{i}{a} = context.ReadVarInt();"); break;
|
||||
case PropertyTypeKind.Int64: sb.AppendLine($"{i}{a} = context.ReadVarLong();"); break;
|
||||
case PropertyTypeKind.Double: sb.AppendLine($"{i}{a} = context.ReadDoubleUnsafe();"); break;
|
||||
case PropertyTypeKind.Single: sb.AppendLine($"{i}{a} = context.ReadSingleUnsafe();"); break;
|
||||
case PropertyTypeKind.Decimal: sb.AppendLine($"{i}{a} = context.ReadDecimalUnsafe();"); break;
|
||||
case PropertyTypeKind.DateTime: sb.AppendLine($"{i}{a} = context.ReadDateTimeUnsafe();"); break;
|
||||
case PropertyTypeKind.Guid: sb.AppendLine($"{i}{a} = context.ReadGuidUnsafe();"); break;
|
||||
case PropertyTypeKind.Byte: sb.AppendLine($"{i}{a} = context.ReadByte();"); break;
|
||||
case PropertyTypeKind.Int16: sb.AppendLine($"{i}{a} = context.ReadInt16Unsafe();"); break;
|
||||
case PropertyTypeKind.UInt16: sb.AppendLine($"{i}{a} = context.ReadUInt16Unsafe();"); break;
|
||||
case PropertyTypeKind.UInt32: sb.AppendLine($"{i}{a} = context.ReadVarUInt();"); break;
|
||||
case PropertyTypeKind.UInt64: sb.AppendLine($"{i}{a} = context.ReadVarULong();"); break;
|
||||
case PropertyTypeKind.TimeSpan: sb.AppendLine($"{i}{a} = new System.TimeSpan(context.ReadRaw<long>());"); break;
|
||||
case PropertyTypeKind.DateTimeOffset: sb.AppendLine($"{i}{a} = context.ReadDateTimeOffsetUnsafe();"); break;
|
||||
case PropertyTypeKind.Boolean: sb.AppendLine($"{i}{a} = context.ReadByte() != 0;"); break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline string read from type code. Handles all H2Q6 (v3 wire format) string markers:
|
||||
/// FixStr (short-form universal, 135-166), String (long-form universal, 167),
|
||||
/// StringUtf16 (FastWire marker, 91),
|
||||
/// StringInternFirstSmall/Medium (interning tiers, 104/105),
|
||||
/// StringInterned (cache ref, 92), StringEmpty (93), Null.
|
||||
///
|
||||
/// FixStr is checked first as the hot path for short strings; non-ASCII
|
||||
/// tier markers carry both <c>charLen</c> and <c>utf8Len</c> in fixed-width headers (1-pass decode).
|
||||
/// </summary>
|
||||
private static void EmitReadString(StringBuilder sb, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
// FixStr is the hot path — short-form universal marker with charLength in the marker.
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsFixStr({tc}))");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} {a} = context.ReadUniversalFixStr({tc});");
|
||||
sb.AppendLine($"{i}}}");
|
||||
// Switch gives O(1) dispatch via JIT jump table for the remaining markers.
|
||||
sb.AppendLine($"{i}else switch ({tc})");
|
||||
sb.AppendLine($"{i}{{");
|
||||
// Interning case (2nd+ occurrence ref) — only emit when EnableInternStringFeature is enabled
|
||||
// on this type. When disabled, the writer never emits StringInterned markers for this type's
|
||||
// properties, so the reader doesn't need to handle them. ACCORE-BIN-T-K9M3 Phase C.
|
||||
if (enableInternString)
|
||||
{
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringInterned:");
|
||||
sb.AppendLine($"{i} {a} = context.GetInternedString((int)context.ReadVarUInt());");
|
||||
sb.AppendLine($"{i} break;");
|
||||
}
|
||||
// StringUtf16 marker + String. Wire-decode body is shared with the runtime path
|
||||
// (TypeReaderTable + cross-type populate) — see context.ReadStringUtf16Marker()
|
||||
// and ReadUniversalLongString.
|
||||
// These markers are feature-independent: writer emits them on any string property regardless of
|
||||
// intern setting (intern is opt-in per-property via [AcStringIntern] + InternBit).
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringUtf16:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadStringUtf16Marker();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.String:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadUniversalLongString();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
// Interning first-occurrence cases — see comment above.
|
||||
if (enableInternString)
|
||||
{
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstSmall:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringSmall();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstMedium:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringMedium();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
}
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
|
||||
sb.AppendLine($"{i} {a} = null;");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringEmpty:");
|
||||
sb.AppendLine($"{i} {a} = string.Empty;");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a Complex property.
|
||||
/// SGen reader only runs in non-metadata mode → ObjectWithMetadata never appears.
|
||||
/// Compile-time ChildNeedsRefScan eliminates ObjectRefFirst/ObjectRef when provably unused.
|
||||
/// Non-nullable + no ref → ZERO branches (tc consumed but ignored).
|
||||
/// No SGen → runtime fallback via ReadValueGenerated.
|
||||
/// </summary>
|
||||
private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i)
|
||||
{
|
||||
if (!p.HasGeneratedWriter)
|
||||
{
|
||||
// No SGen reader — runtime fallback (rewind + ReadValueGenerated)
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}context._position--;");
|
||||
sb.AppendLine($"{i}{a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = p.WriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
|
||||
var cast = $"({p.TypeNameForTypeof})";
|
||||
|
||||
// Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
|
||||
// with the writer-side EmitDirectCollectionWrite + the sibling EmitReadCollectionElement. The
|
||||
// decision depends EXCLUSIVELY on the child compile-time fact `ChildNeedsRefScan` — the parent
|
||||
// EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's SELF-tracking
|
||||
// emit in the scan pass, not the marker dispatch for child property reads). Asymmetry-bug fix:
|
||||
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
|
||||
if (!RefAwareEmitPredicate.ChildEmitsRefMarker(p))
|
||||
{
|
||||
// Compile-time proven: child never tracked → only Object (+ Null for nullable) in stream
|
||||
// Inline: parent creates instance, calls ReadProperties directly (mirrors EmitDirectObjectWrite)
|
||||
// FixObj slot bytes (0..SlotCount-1) are also valid markers here — populate slot cache
|
||||
// to keep _nextRuntimeSlot in sync with the serializer's _nextTypeSlot counter.
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {{ /* null */ }}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc}); if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1; }}");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// ZERO branches — tc is always Object or FixObj
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc}); if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1; }}");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ref tracking possible — switch on tc (Object / ObjectRefFirst / [Null] / ObjectRef / <Object).
|
||||
// The 4 known TypeCode constants are emitted as switch cases — the JIT compiles them as a
|
||||
// jump-table for O(1) dispatch (vs the previous if-else chain's sequential ==-compares).
|
||||
// The polymorphic FixObj range-check (tc < Object) goes into the default branch — runtime
|
||||
// bridge path is rare on a typical SGen graph, so default fall-through is acceptable.
|
||||
// Inline: parent creates instance + handles cache registration.
|
||||
sb.AppendLine($"{i}switch ({tc})");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Object:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRefFirst:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var ci_{p.Name} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} context.RegisterInternedValueAt(ci_{p.Name}, rc_{p.Name});");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
if (p.IsNullable)
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Null: break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRef:");
|
||||
sb.AppendLine($"{i} {a} = {cast}context.GetInternedObject((int)context.ReadVarUInt())!;");
|
||||
sb.AppendLine($"{i} break;");
|
||||
// FixObj slot (0..SlotCount-1): same type via FixObj marker (non-meta, non-ref mode).
|
||||
// Populate slot cache to keep _nextRuntimeSlot in sync with the serializer.
|
||||
sb.AppendLine($"{i} default:");
|
||||
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc});");
|
||||
sb.AppendLine($"{i} if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1;");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when collection element reading can be inlined (no runtime ReadValue dispatch needed).
|
||||
/// </summary>
|
||||
private static bool CanInlineCollectionRead(PropInfo p)
|
||||
{
|
||||
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter) return true;
|
||||
if (p.ElementKind == PropertyTypeKind.String) return true;
|
||||
if (p.ElementKind == PropertyTypeKind.Enum) return true;
|
||||
if (IsMarkerless(p.ElementKind)) return true; // all primitives
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a Collection property.
|
||||
/// Known collection kind + inlineable element → inline Array loop with direct element reads.
|
||||
/// Else → runtime fallback via ReadValueGenerated.
|
||||
/// </summary>
|
||||
private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
// Check if we can inline: known collection shape + inlineable element type
|
||||
if (p.CollectionKind != null && CanInlineCollectionRead(p))
|
||||
{
|
||||
EmitReadCollectionInline(sb, p, a, tc, i, enableInternString);
|
||||
return;
|
||||
}
|
||||
|
||||
// Runtime fallback
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}context._position--;");
|
||||
sb.AppendLine($"{i}{a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a Dictionary property.
|
||||
/// Wire format: [Dictionary][VarUInt count][key₁ value₁ key₂ value₂ ...].
|
||||
/// Keys and values are read inline when their types are known (primitive/string/Complex+SGen).
|
||||
/// </summary>
|
||||
private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
var s = p.Name;
|
||||
var keyType = p.DictKeyTypeName ?? "object";
|
||||
var valType = p.DictValueTypeName ?? "object";
|
||||
|
||||
// Can we inline key/value reads?
|
||||
var canInlineKey = p.DictKeyKind == PropertyTypeKind.String || IsMarkerless(p.DictKeyKind) || p.DictKeyKind == PropertyTypeKind.Enum;
|
||||
var canInlineValue = p.DictValueKind == PropertyTypeKind.String || IsMarkerless(p.DictValueKind) || p.DictValueKind == PropertyTypeKind.Enum
|
||||
|| (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter);
|
||||
var canInline = canInlineKey || canInlineValue; // partial inline is still beneficial
|
||||
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Dictionary)");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Dictionary)");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var cnt_{s} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var dict_{s} = new System.Collections.Generic.Dictionary<{keyType}, {valType}>(cnt_{s});");
|
||||
sb.AppendLine($"{i} for (var di_{s} = 0; di_{s} < cnt_{s}; di_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
|
||||
// Read key
|
||||
if (canInlineKey)
|
||||
EmitReadDictElement(sb, p.DictKeyKind, keyType, $"dk_{s}", s, i + " ", null, false, enableInternString);
|
||||
else
|
||||
sb.AppendLine($"{i} var dk_{s} = ({keyType})AcBinaryDeserializer.ReadValueGenerated(context, typeof({keyType}))!;");
|
||||
|
||||
// Read value
|
||||
if (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter)
|
||||
{
|
||||
var valReader = p.DictValueWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
|
||||
var vtc = $"vtc_{s}";
|
||||
sb.AppendLine($"{i} var {vtc} = context.ReadByte();");
|
||||
sb.AppendLine($"{i} {valType}? dv_{s} = null;");
|
||||
sb.AppendLine($"{i} if ({vtc} == BinaryTypeCode.Object)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var rv_{s} = new {valType}();");
|
||||
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
|
||||
sb.AppendLine($"{i} dv_{s} = rv_{s};");
|
||||
sb.AppendLine($"{i} }}");
|
||||
// ObjectRefFirst / ObjectRef cases — routed through RefAwareEmitPredicate. Single source of
|
||||
// truth shared with EmitReadComplex / EmitReadCollectionElement / EmitDirectCollectionWrite.
|
||||
// The decision depends EXCLUSIVELY on the dict-value compile-time fact `DictValueNeedsRefScan`
|
||||
// — the parent EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's
|
||||
// SELF-tracking emit in the scan pass, GenWriter.cs:140). Symmetric with the writer-side
|
||||
// dict-value emit. Asymmetry-bug fix: see AcBinarySerializerIIdReferenceTests
|
||||
// .Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
|
||||
if (RefAwareEmitPredicate.DictValueEmitsRefMarker(p))
|
||||
{
|
||||
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRefFirst)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var rci_{s} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var rv_{s} = new {valType}();");
|
||||
sb.AppendLine($"{i} context.RegisterInternedValueAt(rci_{s}, rv_{s});");
|
||||
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
|
||||
sb.AppendLine($"{i} dv_{s} = rv_{s};");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRef)");
|
||||
sb.AppendLine($"{i} dv_{s} = ({valType})context.GetInternedObject((int)context.ReadVarUInt())!;");
|
||||
}
|
||||
sb.AppendLine($"{i} else if ({vtc} != BinaryTypeCode.Null)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
sb.AppendLine($"{i} dv_{s} = ({valType}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({valType}));");
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else if (canInlineValue)
|
||||
EmitReadDictElement(sb, p.DictValueKind, valType, $"dv_{s}", s, i + " ", null, true, enableInternString);
|
||||
else
|
||||
sb.AppendLine($"{i} var dv_{s} = ({valType}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({valType}));");
|
||||
|
||||
// Add to dictionary
|
||||
sb.AppendLine($"{i} if (dk_{s} != null) dict_{s}[dk_{s}] = dv_{s}!;");
|
||||
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} {a} = dict_{s};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a single dictionary key or value element.
|
||||
/// Reads type code byte, then dispatches based on element kind.
|
||||
/// </summary>
|
||||
private static void EmitReadDictElement(StringBuilder sb, PropertyTypeKind kind, string typeName, string varName, string propSuffix, string i, PropInfo? p, bool isRefType, bool enableInternString)
|
||||
{
|
||||
var etc = $"{varName}_tc";
|
||||
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
|
||||
|
||||
if (kind == PropertyTypeKind.String)
|
||||
{
|
||||
sb.AppendLine($"{i}{typeName}? {varName} = null;");
|
||||
EmitReadString(sb, varName, etc, i, enableInternString);
|
||||
}
|
||||
else if (kind == PropertyTypeKind.Enum)
|
||||
{
|
||||
sb.AppendLine($"{i}{typeName} {varName} = default;");
|
||||
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Enum)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var eb = context.ReadByte();");
|
||||
sb.AppendLine($"{i} int eiv;");
|
||||
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) eiv = BinaryTypeCode.DecodeTinyInt(eb);");
|
||||
sb.AppendLine($"{i} else eiv = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i} {varName} = ({typeName})(object)eiv;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({etc})) {varName} = ({typeName})(object)BinaryTypeCode.DecodeTinyInt({etc});");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Primitive value type — never nullable
|
||||
sb.AppendLine($"{i}{typeName} {varName} = default;");
|
||||
EmitReadMarkeredValueForKind(sb, kind, varName, etc, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits markered value read by kind only (no PropInfo needed). For dict key/value inline reads.
|
||||
/// </summary>
|
||||
private static void EmitReadMarkeredValueForKind(StringBuilder sb, PropertyTypeKind k, string a, string tc, string i)
|
||||
{
|
||||
switch (k)
|
||||
{
|
||||
case PropertyTypeKind.Int32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {a} = context.ReadVarInt();");
|
||||
break;
|
||||
case PropertyTypeKind.Int64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {a} = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int64) {a} = context.ReadVarLong();");
|
||||
break;
|
||||
case PropertyTypeKind.Boolean:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.True) {a} = true;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.False) {a} = false;");
|
||||
break;
|
||||
case PropertyTypeKind.Double:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float64) {a} = context.ReadDoubleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Single:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float32) {a} = context.ReadSingleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Decimal:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Decimal) {a} = context.ReadDecimalUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTime:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTime) {a} = context.ReadDateTimeUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Guid:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Guid) {a} = context.ReadGuidUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Byte:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (byte)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt8) {a} = context.ReadByte();");
|
||||
break;
|
||||
case PropertyTypeKind.Int16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (short)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int16) {a} = context.ReadInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (ushort)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt16) {a} = context.ReadUInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (uint)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt32) {a} = context.ReadVarUInt();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (ulong)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt64) {a} = context.ReadVarULong();");
|
||||
break;
|
||||
case PropertyTypeKind.TimeSpan:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.TimeSpan) {a} = context.ReadTimeSpanUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTimeOffset:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTimeOffset) {a} = context.ReadDateTimeOffsetUnsafe();");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline collection read: Array marker already consumed as tc.
|
||||
/// Reads count + loops with direct element reads (Complex with SGen, or primitive/string/enum).
|
||||
/// Eliminates per-element: ReadValue dispatch, ReadObjectCore dict lookup, Activator.CreateInstance.
|
||||
/// </summary>
|
||||
private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
var isComplexElement = p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter;
|
||||
var elemType = p.ElementFullTypeName!;
|
||||
var s = p.Name;
|
||||
|
||||
// Null check
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Array)");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Array)");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var cnt_{s} = (int)context.ReadVarUInt();");
|
||||
|
||||
// Create collection + loop based on kind
|
||||
if (p.CollectionKind == "Array")
|
||||
{
|
||||
sb.AppendLine($"{i} var col_{s} = new {elemType}[cnt_{s}];");
|
||||
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
if (isComplexElement)
|
||||
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString);
|
||||
else
|
||||
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: true, null, enableInternString);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else if (p.CollectionKind == "Counted" && p.CollectionAddMethod != null)
|
||||
{
|
||||
// Concrete custom collection — use actual type + correct add method
|
||||
if (p.CollectionHasCapacityCtor)
|
||||
sb.AppendLine($"{i} var col_{s} = new {p.TypeNameForTypeof}(cnt_{s});");
|
||||
else
|
||||
sb.AppendLine($"{i} var col_{s} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
if (isComplexElement)
|
||||
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, p.CollectionAddMethod);
|
||||
else
|
||||
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, p.CollectionAddMethod, enableInternString);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else // List, IndexedCollection, Counted-interface → List<T> with Add
|
||||
{
|
||||
sb.AppendLine($"{i} var col_{s} = new System.Collections.Generic.List<{elemType}>(cnt_{s});");
|
||||
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
if (isComplexElement)
|
||||
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString);
|
||||
else
|
||||
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, null, enableInternString);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i} {a} = col_{s};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits per-element read inside collection loop.
|
||||
/// SGen reader = non-metadata mode → no ObjectWithMetadata fallback.
|
||||
/// !needsRefScan → only Object/Null possible → 1 branch per element.
|
||||
/// </summary>
|
||||
private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, string? addMethod = null)
|
||||
{
|
||||
var etc = $"etc_{propSuffix}";
|
||||
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
|
||||
|
||||
var addCall = addMethod ?? "Add";
|
||||
var assignNull = isArray ? $"col_{propSuffix}[{indexVar}] = null!;" : $"col_{propSuffix}.{addCall}(null!);";
|
||||
var assignExpr = isArray ? $"col_{propSuffix}[{indexVar}] = re_{propSuffix};" : $"col_{propSuffix}.{addCall}(re_{propSuffix});";
|
||||
|
||||
// Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
|
||||
// with the writer-side EmitDirectCollectionWrite + EmitReadComplex. The decision depends
|
||||
// EXCLUSIVELY on the element compile-time fact `needsRefScan` — the parent EnableRefHandlingFeature
|
||||
// flag is NOT a factor here. Asymmetry-bug fix:
|
||||
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
|
||||
if (!RefAwareEmitPredicate.ElementEmitsRefMarker(needsRefScan))
|
||||
{
|
||||
// No ref tracking → only Object, FixObj or Null in stream — inline ReadProperties
|
||||
// FixObj slot: populate slot cache to keep _nextRuntimeSlot in sync.
|
||||
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Null) {{ {assignNull} }}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} if ({etc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({elemTypeName}), {etc}); if ({etc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {etc} + 1; }}");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Switch on etc (Object / ObjectRefFirst / Null / ObjectRef / <Object). The JIT emits the
|
||||
// 4 known TypeCode constants as a jump-table (O(1) dispatch); the polymorphic FixObj
|
||||
// range-check (etc < Object) goes into the default branch. Object hot-path stays first.
|
||||
sb.AppendLine($"{i}switch ({etc})");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Object:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRefFirst:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var ci_{propSuffix} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} context.RegisterInternedValueAt(ci_{propSuffix}, re_{propSuffix});");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
|
||||
sb.AppendLine($"{i} {assignNull}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRef:");
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i} col_{propSuffix}[{indexVar}] = {elemCast}context.GetInternedObject((int)context.ReadVarUInt())!;");
|
||||
else
|
||||
sb.AppendLine($"{i} col_{propSuffix}.{addCall}({elemCast}context.GetInternedObject((int)context.ReadVarUInt())!);");
|
||||
sb.AppendLine($"{i} break;");
|
||||
// FixObj slot (0..SlotCount-1): same type via FixObj marker.
|
||||
// Populate slot cache to keep _nextRuntimeSlot in sync with the serializer.
|
||||
sb.AppendLine($"{i} default:");
|
||||
sb.AppendLine($"{i} if ({etc} < BinaryTypeCode.Object)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} context.GetWrapper(typeof({elemTypeName}), {etc});");
|
||||
sb.AppendLine($"{i} if ({etc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {etc} + 1;");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits per-element read for non-Complex collection elements (String, primitive, Enum).
|
||||
/// Reads type code byte, then dispatches based on ElementKind.
|
||||
/// </summary>
|
||||
private static void EmitReadNonComplexCollectionElement(StringBuilder sb, PropInfo p, string indexVar, string propSuffix, string i, bool isArray, string? addMethod, bool enableInternString)
|
||||
{
|
||||
var addCall = addMethod ?? "Add";
|
||||
var elemType = p.ElementFullTypeName!;
|
||||
var colRef = $"col_{propSuffix}";
|
||||
|
||||
// String element FastWire markerless fast-path — same wire as property-level (int32 sentinel header).
|
||||
// All FastWire string writes funnel through `WriteStringWithDispatch.FastWire = WriteStringUtf16Markerless`,
|
||||
// so collection elements use the same markerless format. Skips the etc-read entirely in FastWire mode.
|
||||
if (p.ElementKind == PropertyTypeKind.String)
|
||||
{
|
||||
var tempVar = $"sv_{propSuffix}";
|
||||
sb.AppendLine($"{i}string? {tempVar};");
|
||||
sb.AppendLine($"{i}if (context.FastWire)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} {tempVar} = context.ReadStringUtf16Markerless();");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var etc_{propSuffix} = context.ReadByte();");
|
||||
sb.AppendLine($"{i} {tempVar} = null;");
|
||||
EmitReadString(sb, tempVar, $"etc_{propSuffix}", i + " ", enableInternString);
|
||||
sb.AppendLine($"{i}}}");
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar}!;");
|
||||
else
|
||||
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar}!);");
|
||||
return;
|
||||
}
|
||||
|
||||
var etc = $"etc_{propSuffix}";
|
||||
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
|
||||
|
||||
if (p.ElementKind == PropertyTypeKind.Enum)
|
||||
{
|
||||
// Enum element: Enum marker or TinyInt
|
||||
var tempVar = $"ev_{propSuffix}";
|
||||
sb.AppendLine($"{i}{elemType} {tempVar} = default;");
|
||||
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Enum)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var eb = context.ReadByte();");
|
||||
sb.AppendLine($"{i} int eiv;");
|
||||
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) eiv = BinaryTypeCode.DecodeTinyInt(eb);");
|
||||
sb.AppendLine($"{i} else eiv = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i} {tempVar} = ({elemType})(object)eiv;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({etc})) {tempVar} = ({elemType})(object)BinaryTypeCode.DecodeTinyInt({etc});");
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar};");
|
||||
else
|
||||
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar});");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Primitive element: read markered value
|
||||
var tempVar = $"pv_{propSuffix}";
|
||||
sb.AppendLine($"{i}{elemType} {tempVar} = default;");
|
||||
// Create a minimal PropInfo-like context for EmitReadMarkeredValue
|
||||
EmitReadMarkeredValue(sb, p.ElementKind, tempVar, etc, i, p, nullable: false);
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar};");
|
||||
else
|
||||
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar});");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits markered value read for primitive types (with type code already read).
|
||||
/// Handles TinyInt encoding for integer types.
|
||||
/// </summary>
|
||||
private static void EmitReadMarkeredValue(StringBuilder sb, PropertyTypeKind k, string a, string tc, string i, PropInfo p, bool nullable)
|
||||
{
|
||||
var assign = nullable ? $"{a} = " : $"{a} = ";
|
||||
switch (k)
|
||||
{
|
||||
case PropertyTypeKind.Int32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {assign}context.ReadVarInt();");
|
||||
break;
|
||||
case PropertyTypeKind.Int64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {assign}context.ReadVarInt();");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int64) {assign}context.ReadVarLong();");
|
||||
break;
|
||||
case PropertyTypeKind.Boolean:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.True) {assign}true;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.False) {assign}false;");
|
||||
break;
|
||||
case PropertyTypeKind.Double:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float64) {assign}context.ReadDoubleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Single:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float32) {assign}context.ReadSingleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Decimal:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Decimal) {assign}context.ReadDecimalUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTime:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTime) {assign}context.ReadDateTimeUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Guid:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Guid) {assign}context.ReadGuidUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Byte:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(byte)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt8) {assign}context.ReadByte();");
|
||||
break;
|
||||
case PropertyTypeKind.Int16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(short)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int16) {assign}context.ReadInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(ushort)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt16) {assign}context.ReadUInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(uint)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt32) {assign}context.ReadVarUInt();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(ulong)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt64) {assign}context.ReadVarULong();");
|
||||
break;
|
||||
case PropertyTypeKind.Enum:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Enum)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var eb = context.ReadByte();");
|
||||
sb.AppendLine($"{i} int ev;");
|
||||
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) ev = BinaryTypeCode.DecodeTinyInt(eb);");
|
||||
sb.AppendLine($"{i} else ev = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i} {assign}({p.TypeNameForTypeof})(object)ev;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({tc})) {assign}({p.TypeNameForTypeof})(object)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
break;
|
||||
case PropertyTypeKind.TimeSpan:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.TimeSpan) {assign}context.ReadTimeSpanUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTimeOffset:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTimeOffset) {assign}context.ReadDateTimeOffsetUnsafe();");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,363 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Class-info extraction pass — transforms a Roslyn <see cref="GeneratorAttributeSyntaxContext"/>
|
||||
/// (a class/struct annotated with <c>[AcBinarySerializable]</c>) into the <see cref="SerializableClassInfo"/>
|
||||
/// model consumed by the emit passes (writer / reader / scan / init).
|
||||
///
|
||||
/// <para>Reads the attribute's feature flags (1-, 4-, 5-, 6-bool ctor variants), walks the inheritance
|
||||
/// hierarchy via <c>GetAllSerializablePropertySymbols</c>, and computes per-property metadata: kind,
|
||||
/// nullability, intern eligibility, complex / collection / dictionary element types, generated-writer
|
||||
/// pointers, FNV hashes for inline-metadata, and recursive scan-need flags.</para>
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
private static SerializableClassInfo? GetClassInfo(GeneratorAttributeSyntaxContext context)
|
||||
{
|
||||
if (!(context.TargetSymbol is INamedTypeSymbol typeSymbol))
|
||||
return null;
|
||||
|
||||
var namespaceName = typeSymbol.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty
|
||||
: typeSymbol.ContainingNamespace.ToDisplayString();
|
||||
|
||||
var properties = new List<PropInfo>();
|
||||
|
||||
// Read feature flags from [AcBinarySerializable] — disabled features eliminate
|
||||
// corresponding code blocks from generated ScanObject/WriteProperties.
|
||||
var enableIdTracking = true;
|
||||
var enableRefHandling = true;
|
||||
var enableInternString = true;
|
||||
var enableMetadata = true;
|
||||
var enablePropertyFilter = true;
|
||||
var enablePolymorphDetect = true;
|
||||
var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (binarySerializableAttr != null)
|
||||
{
|
||||
if (binarySerializableAttr.ConstructorArguments.Length == 1)
|
||||
{
|
||||
// Single bool ctor: AcBinarySerializable(enableAllFeatures)
|
||||
var all = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = all;
|
||||
enableRefHandling = all;
|
||||
enableInternString = all;
|
||||
enableMetadata = all;
|
||||
enablePropertyFilter = all;
|
||||
enablePolymorphDetect = all;
|
||||
}
|
||||
else if (binarySerializableAttr.ConstructorArguments.Length == 4)
|
||||
{
|
||||
// Four bool ctor: (metadata, idTracking, refHandling, internString) — filter + polymorph default to true
|
||||
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
|
||||
}
|
||||
else if (binarySerializableAttr.ConstructorArguments.Length == 5)
|
||||
{
|
||||
// Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) — polymorph defaults to true
|
||||
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
|
||||
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
|
||||
}
|
||||
else if (binarySerializableAttr.ConstructorArguments.Length == 6)
|
||||
{
|
||||
// Six bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter, polymorphDetect)
|
||||
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
|
||||
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
|
||||
enablePolymorphDetect = (bool)binarySerializableAttr.ConstructorArguments[5].Value!;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var p in GetAllSerializablePropertySymbols(typeSymbol))
|
||||
{
|
||||
// String interning attribútum detektálás (null = no attr, true/false = explicit)
|
||||
bool? stringInternAttr = null;
|
||||
if (!enableInternString)
|
||||
{
|
||||
stringInternAttr = false;
|
||||
}
|
||||
else if (GetKind(p.Type) == PropertyTypeKind.String)
|
||||
{
|
||||
var attr = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute");
|
||||
if (attr != null && attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Primitive)
|
||||
{
|
||||
stringInternAttr = (bool)attr.ConstructorArguments[0].Value!;
|
||||
}
|
||||
}
|
||||
|
||||
// For typeof(): strip trailing '?' from nullable reference types (typeof(T?) is invalid for ref types)
|
||||
// Nullable value types (int?, Guid?) keep '?' because typeof(int?) == typeof(Nullable<int>) is valid
|
||||
var typeDisplayName = p.Type.ToDisplayString();
|
||||
var typeNameForTypeof = (p.Type.NullableAnnotation == NullableAnnotation.Annotated && !p.Type.IsValueType)
|
||||
? typeDisplayName.TrimEnd('?')
|
||||
: typeDisplayName;
|
||||
|
||||
// Direct object write detection for Complex property types:
|
||||
// Check if the property type has [AcBinarySerializable] (→ has generated writer)
|
||||
// and if it implements IId<T> (→ needs ref tracking in generated code)
|
||||
var kind = GetKind(p.Type);
|
||||
bool hasGenWriter = false;
|
||||
bool propTypeIsIId = false;
|
||||
bool propEnableMetadata = true;
|
||||
bool childNeedsIdScan = true;
|
||||
bool childNeedsAllRefScan = true;
|
||||
bool childNeedsInternScan = true;
|
||||
string? writerClassName = null;
|
||||
string? propIdTypeName = null;
|
||||
int childTypeNameHash = 0;
|
||||
int[]? childPropertyHashes = null;
|
||||
if (kind == PropertyTypeKind.Complex)
|
||||
{
|
||||
// Resolve to the actual type symbol (strip nullable annotation for ref types)
|
||||
// For SharedTag? → SharedTag. OriginalDefinition handles generic types.
|
||||
var resolvedType = p.Type is INamedTypeSymbol namedPropType
|
||||
? namedPropType.OriginalDefinition
|
||||
: p.Type;
|
||||
|
||||
hasGenWriter = resolvedType.Locations.Any(l => l.IsInSource)
|
||||
&& resolvedType.GetAttributes().Any(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
|
||||
if (hasGenWriter)
|
||||
{
|
||||
// Read child type's EnableMetadataFeature
|
||||
propEnableMetadata = ReadEnableMetadata(resolvedType);
|
||||
var childScanFlags = ComputeNeedsScan(resolvedType);
|
||||
childNeedsIdScan = childScanFlags.needsIdScan;
|
||||
childNeedsAllRefScan = childScanFlags.needsAllRefScan;
|
||||
childNeedsInternScan = childScanFlags.needsInternScan;
|
||||
var iidIface = resolvedType.AllInterfaces.FirstOrDefault(i =>
|
||||
i.IsGenericType &&
|
||||
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
propTypeIsIId = iidIface != null;
|
||||
if (iidIface != null)
|
||||
propIdTypeName = iidIface.TypeArguments[0].ToDisplayString();
|
||||
|
||||
// Writer class: {Namespace}.{FlatName}_GeneratedWriter
|
||||
var flatName = BuildFlatName((INamedTypeSymbol)resolvedType);
|
||||
var ns = resolvedType.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty
|
||||
: resolvedType.ContainingNamespace.ToDisplayString();
|
||||
writerClassName = string.IsNullOrEmpty(ns)
|
||||
? $"{flatName}_GeneratedWriter"
|
||||
: $"{ns}.{flatName}_GeneratedWriter";
|
||||
|
||||
// UseMetadata: compute child type hash-es for inline metadata
|
||||
childTypeNameHash = ComputeFnvHash(resolvedType.Name);
|
||||
childPropertyHashes = ComputeChildPropertyHashes(resolvedType);
|
||||
}
|
||||
}
|
||||
|
||||
// Collection element type analysis for inline collection write
|
||||
PropertyTypeKind elemKind = PropertyTypeKind.Unknown;
|
||||
bool elemHasGenWriter = false;
|
||||
bool elemIsIId = false;
|
||||
bool elemEnableMetadata = true;
|
||||
bool elemNeedsIdScan = true;
|
||||
bool elemNeedsAllRefScan = true;
|
||||
bool elemNeedsInternScan = true;
|
||||
string? elemWriterClassName = null;
|
||||
string? elemIdTypeName = null;
|
||||
string? collKind = null;
|
||||
string? collAddMethod = null;
|
||||
bool collHasCapacityCtor = false;
|
||||
string? elemFullTypeName = null;
|
||||
int elementTypeNameHash = 0;
|
||||
int[]? elementPropertyHashes = null;
|
||||
if (kind == PropertyTypeKind.Collection)
|
||||
{
|
||||
var elemType = GetCollectionElementType(p.Type);
|
||||
if (elemType != null)
|
||||
{
|
||||
elemKind = GetKind(elemType);
|
||||
elemFullTypeName = elemType.ToDisplayString();
|
||||
|
||||
// Detect collection shape for inline write
|
||||
if (p.Type is IArrayTypeSymbol)
|
||||
collKind = "Array";
|
||||
else if (p.Type is INamedTypeSymbol collNamedType)
|
||||
{
|
||||
var origDef = collNamedType.OriginalDefinition.ToDisplayString();
|
||||
collKind = origDef switch
|
||||
{
|
||||
"System.Collections.Generic.List<T>" => "List",
|
||||
"System.Collections.Generic.IList<T>" => "IndexedCollection",
|
||||
"System.Collections.Generic.IReadOnlyList<T>" => "IndexedCollection",
|
||||
"System.Collections.Generic.HashSet<T>" => "Counted", // has Count, no indexer
|
||||
"System.Collections.Generic.Queue<T>" => "Counted",
|
||||
"System.Collections.Generic.ICollection<T>" => "Counted",
|
||||
"System.Collections.Generic.IReadOnlyCollection<T>" => "Counted",
|
||||
"System.Collections.Generic.SortedSet<T>" => "Counted",
|
||||
"System.Collections.Generic.LinkedList<T>" => "Counted",
|
||||
_ => null
|
||||
};
|
||||
|
||||
// Determine add method + capacity ctor for Counted concrete types
|
||||
if (collKind == "Counted")
|
||||
{
|
||||
collAddMethod = origDef switch
|
||||
{
|
||||
"System.Collections.Generic.HashSet<T>" => "Add",
|
||||
"System.Collections.Generic.SortedSet<T>" => "Add",
|
||||
"System.Collections.Generic.Queue<T>" => "Enqueue",
|
||||
"System.Collections.Generic.LinkedList<T>" => "AddLast",
|
||||
_ => null // ICollection<T>, IReadOnlyCollection<T> → backed by List<T>
|
||||
};
|
||||
collHasCapacityCtor = origDef is
|
||||
"System.Collections.Generic.HashSet<T>" or
|
||||
"System.Collections.Generic.Queue<T>";
|
||||
}
|
||||
}
|
||||
|
||||
// For Complex element types, check for generated writer
|
||||
if (elemKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedElem = elemType is INamedTypeSymbol namedElem
|
||||
? namedElem.OriginalDefinition : elemType;
|
||||
elemHasGenWriter = resolvedElem.Locations.Any(l => l.IsInSource)
|
||||
&& resolvedElem.GetAttributes().Any(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (elemHasGenWriter)
|
||||
{
|
||||
// Read element type's EnableMetadataFeature
|
||||
elemEnableMetadata = ReadEnableMetadata(resolvedElem);
|
||||
var elemScanFlags = ComputeNeedsScan(resolvedElem);
|
||||
elemNeedsIdScan = elemScanFlags.needsIdScan;
|
||||
elemNeedsAllRefScan = elemScanFlags.needsAllRefScan;
|
||||
elemNeedsInternScan = elemScanFlags.needsInternScan;
|
||||
var elemIidIface = resolvedElem.AllInterfaces.FirstOrDefault(ifc =>
|
||||
ifc.IsGenericType &&
|
||||
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
elemIsIId = elemIidIface != null;
|
||||
if (elemIidIface != null)
|
||||
elemIdTypeName = elemIidIface.TypeArguments[0].ToDisplayString();
|
||||
|
||||
var elemFlatName = BuildFlatName((INamedTypeSymbol)resolvedElem);
|
||||
var ens = resolvedElem.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty : resolvedElem.ContainingNamespace.ToDisplayString();
|
||||
elemWriterClassName = string.IsNullOrEmpty(ens)
|
||||
? $"{elemFlatName}_GeneratedWriter"
|
||||
: $"{ens}.{elemFlatName}_GeneratedWriter";
|
||||
|
||||
// UseMetadata: compute element type hash-es for inline metadata
|
||||
elementTypeNameHash = ComputeFnvHash(resolvedElem.Name);
|
||||
elementPropertyHashes = ComputeChildPropertyHashes(resolvedElem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dictionary key/value type analysis for inline dictionary read
|
||||
PropertyTypeKind dictKeyKind = PropertyTypeKind.Unknown;
|
||||
PropertyTypeKind dictValueKind = PropertyTypeKind.Unknown;
|
||||
string? dictKeyTypeName = null;
|
||||
string? dictValueTypeName = null;
|
||||
bool dictValueHasGenWriter = false;
|
||||
string? dictValueWriterClassName = null;
|
||||
bool dictValueIsIId = false;
|
||||
bool dictValueEnableMetadata = true;
|
||||
bool dictValueNeedsIdScan = true;
|
||||
bool dictValueNeedsAllRefScan = true;
|
||||
bool dictValueNeedsInternScan = true;
|
||||
int dictValueTypeNameHash = 0;
|
||||
int[]? dictValuePropertyHashes = null;
|
||||
if (kind == PropertyTypeKind.Dictionary)
|
||||
{
|
||||
var (keyType, valueType) = GetDictionaryKeyValueTypes(p.Type);
|
||||
if (keyType != null)
|
||||
{
|
||||
dictKeyKind = GetKind(keyType);
|
||||
dictKeyTypeName = keyType.ToDisplayString();
|
||||
}
|
||||
if (valueType != null)
|
||||
{
|
||||
dictValueKind = GetKind(valueType);
|
||||
dictValueTypeName = valueType.ToDisplayString();
|
||||
if (dictValueKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedValue = valueType is INamedTypeSymbol nvt ? nvt.OriginalDefinition : valueType;
|
||||
dictValueHasGenWriter = resolvedValue.Locations.Any(l => l.IsInSource)
|
||||
&& resolvedValue.GetAttributes().Any(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (dictValueHasGenWriter)
|
||||
{
|
||||
var vfn = BuildFlatName((INamedTypeSymbol)resolvedValue);
|
||||
var vns = resolvedValue.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty : resolvedValue.ContainingNamespace.ToDisplayString();
|
||||
dictValueWriterClassName = string.IsNullOrEmpty(vns)
|
||||
? $"{vfn}_GeneratedWriter"
|
||||
: $"{vns}.{vfn}_GeneratedWriter";
|
||||
|
||||
dictValueEnableMetadata = ReadEnableMetadata(resolvedValue);
|
||||
var dvScanFlags = ComputeNeedsScan(resolvedValue);
|
||||
dictValueNeedsIdScan = dvScanFlags.needsIdScan;
|
||||
dictValueNeedsAllRefScan = dvScanFlags.needsAllRefScan;
|
||||
dictValueNeedsInternScan = dvScanFlags.needsInternScan;
|
||||
var dvIidIface = resolvedValue.AllInterfaces.FirstOrDefault(ifc =>
|
||||
ifc.IsGenericType &&
|
||||
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
dictValueIsIId = dvIidIface != null;
|
||||
dictValueTypeNameHash = ComputeFnvHash(resolvedValue.Name);
|
||||
dictValuePropertyHashes = ComputeChildPropertyHashes(resolvedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
properties.Add(new PropInfo(
|
||||
p.Name,
|
||||
typeDisplayName,
|
||||
typeNameForTypeof,
|
||||
kind,
|
||||
p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type),
|
||||
p.Type.SpecialType == SpecialType.System_Object,
|
||||
stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName, propIdTypeName,
|
||||
elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, elemIdTypeName, collKind, elemFullTypeName,
|
||||
collAddMethod, collHasCapacityCtor,
|
||||
dictKeyKind, dictValueKind, dictKeyTypeName, dictValueTypeName, dictValueHasGenWriter, dictValueWriterClassName,
|
||||
dictValueIsIId, dictValueEnableMetadata, dictValueTypeNameHash, dictValuePropertyHashes,
|
||||
dictValueNeedsIdScan, dictValueNeedsAllRefScan, dictValueNeedsInternScan,
|
||||
childTypeNameHash, childPropertyHashes,
|
||||
elementTypeNameHash, elementPropertyHashes,
|
||||
propEnableMetadata, elemEnableMetadata,
|
||||
childNeedsIdScan, childNeedsAllRefScan, childNeedsInternScan,
|
||||
elemNeedsIdScan, elemNeedsAllRefScan, elemNeedsInternScan));
|
||||
}
|
||||
|
||||
// IId<T>: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering
|
||||
// If EnableIdTrackingFeature == false, skip IId detection entirely → isIId = false
|
||||
var isIId = false;
|
||||
string? idTypeName = null;
|
||||
if (enableIdTracking)
|
||||
{
|
||||
var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i =>
|
||||
i.IsGenericType &&
|
||||
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
if (iidInterface != null)
|
||||
{
|
||||
isIId = true;
|
||||
idTypeName = iidInterface.TypeArguments[0].ToDisplayString();
|
||||
}
|
||||
}
|
||||
|
||||
// Properties are already in runtime-matching order from GetAllSerializablePropertySymbols:
|
||||
// derived → base, each level sorted alphabetically (matches TypeMetadataBase.GetUnfilteredProperties).
|
||||
|
||||
var className = BuildFlatName(typeSymbol);
|
||||
var typeNameHash = ComputeFnvHash(typeSymbol.Name);
|
||||
var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray();
|
||||
var selfScanFlags = ComputeNeedsScan(typeSymbol);
|
||||
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, enablePolymorphDetect, enableInternString, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
// Source-generator model types — pure POCO data carriers describing a `[AcBinarySerializable]` type
|
||||
// and its serializable properties. Consumed by all emit / diagnostics / analysis passes in the partial
|
||||
// `AcBinarySourceGenerator` class (see siblings `*.GenWriter.cs`, `*.GenReader.cs`, etc.).
|
||||
|
||||
internal sealed class SerializableClassInfo
|
||||
{
|
||||
public string Namespace { get; }
|
||||
public string ClassName { get; }
|
||||
public string FullTypeName { get; }
|
||||
public List<PropInfo> Properties { get; }
|
||||
/// <summary>True if this type implements IId<T></summary>
|
||||
public bool IsIId { get; }
|
||||
/// <summary>The Id type name ("int", "long", "System.Guid") if IsIId, null otherwise</summary>
|
||||
public string? IdTypeName { get; }
|
||||
/// <summary>True if EnableRefHandlingFeature is enabled — controls non-IId All mode tracking code emission.</summary>
|
||||
public bool EnableRefHandling { get; }
|
||||
/// <summary>FNV-1a hash of ClassName (matches runtime SourceType.Name hash)</summary>
|
||||
public int TypeNameHash { get; }
|
||||
/// <summary>FNV-1a hash of each property name, in property order</summary>
|
||||
public int[] PropertyNameHashes { get; }
|
||||
/// <summary>When false, skip inline metadata and use markerless property write for this type.</summary>
|
||||
public bool EnableMetadata { get; }
|
||||
/// <summary>True if EnablePropertyFilterFeature is enabled — controls per-property HasPropertyFilter
|
||||
/// guard emission in WriteProperties / ScanObject. When false, the filter check is omitted entirely
|
||||
/// → leaner generated code on the hot path (typical for high-throughput types that never use a filter).</summary>
|
||||
public bool EnablePropertyFilter { get; }
|
||||
/// <summary>True if EnablePolymorphDetectFeature is enabled — controls <c>ObjectWithTypeName</c> + AQN
|
||||
/// prefix emit on <c>System.Object</c>-declared properties. When false, the prefix is suppressed
|
||||
/// AND ACBIN002 fires at build time if such a property exists on this type (guarding against silent
|
||||
/// wire corruption). Opt-out is intentional: dev guarantees no polymorphic <c>object</c> property
|
||||
/// will be serialized on this type, or all such properties are excluded via <c>[AcBinaryIgnore]</c>.</summary>
|
||||
public bool EnablePolymorphDetect { get; }
|
||||
/// <summary>True if EnableInternStringFeature is enabled — controls whether the SGen-emitted reader
|
||||
/// contains <c>StringInterned</c>, <c>StringInternFirstSmall</c>, <c>StringInternFirstMedium</c> case-ágakat.
|
||||
/// When false, those cases are omitted (the writer doesn't emit those markers when intern is off,
|
||||
/// so the reader doesn't need to handle them). Leaner switch dispatch (~30% fewer string cases) +
|
||||
/// smaller IL → faster cold-start JIT + smaller AOT publish.</summary>
|
||||
public bool EnableInternString { get; }
|
||||
/// <summary>When true, type subtree has IId types needing scan (active in OnlyId + All).</summary>
|
||||
public bool NeedsIdScan { get; }
|
||||
/// <summary>When true, type subtree has non-IId ref tracking (active only in All mode).</summary>
|
||||
public bool NeedsAllRefScan { get; }
|
||||
/// <summary>When true, type subtree needs string interning scan.</summary>
|
||||
public bool NeedsInternScan { get; }
|
||||
/// <summary>Derived: NeedsIdScan || NeedsAllRefScan.</summary>
|
||||
public bool NeedsRefScan => NeedsIdScan || NeedsAllRefScan;
|
||||
/// <summary>Derived: any scan axis active.</summary>
|
||||
public bool NeedsScan => NeedsIdScan || NeedsAllRefScan || NeedsInternScan;
|
||||
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect, bool enableInternString, bool needsIdScan, bool needsAllRefScan, bool needsInternScan)
|
||||
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; EnablePolymorphDetect = enablePolymorphDetect; EnableInternString = enableInternString; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; }
|
||||
}
|
||||
|
||||
internal sealed class PropInfo
|
||||
{
|
||||
public string Name { get; }
|
||||
public string TypeName { get; }
|
||||
/// <summary>
|
||||
/// Type name safe for typeof() — nullable ref type annotation stripped (typeof(T?) invalid for ref types).
|
||||
/// </summary>
|
||||
public string TypeNameForTypeof { get; }
|
||||
public PropertyTypeKind TypeKind { get; }
|
||||
public bool IsNullable { get; }
|
||||
/// <summary>
|
||||
/// Pre-computed interning flags matching runtime BinaryPropertyAccessorBase._interningFlags.
|
||||
/// Bit layout: bit N = eligible when StringInterningMode == N.
|
||||
/// None=0 → bit 0 never set. Attribute=1 → bit 1. All=2 → bit 2.
|
||||
/// No attr: 0b100 (4), [AcStringIntern(true)]: 0b110 (6), [AcStringIntern(false)]: 0b000 (0).
|
||||
/// </summary>
|
||||
public int InterningFlags { get; }
|
||||
|
||||
/// <summary>True when declared property type is System.Object. Runtime type dispatch needed.</summary>
|
||||
public bool IsObjectDeclaredType { get; }
|
||||
/// <summary>True if the Complex property type has [AcBinarySerializable] → has a generated writer.</summary>
|
||||
public bool HasGeneratedWriter { get; }
|
||||
/// <summary>True if the Complex property type implements IId<T> → needs ref tracking in write pass.</summary>
|
||||
public bool IsIId { get; }
|
||||
/// <summary>Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter.</summary>
|
||||
public string? WriterClassName { get; }
|
||||
/// <summary>Id type name ("int", "long", "System.Guid") for IId child types. Null if not IId.</summary>
|
||||
public string? IdTypeName { get; }
|
||||
|
||||
// Collection element metadata — set when TypeKind == Collection and element type is Complex with generated writer
|
||||
/// <summary>Element type kind for collection properties. Only meaningful when TypeKind == Collection.</summary>
|
||||
public PropertyTypeKind ElementKind { get; }
|
||||
/// <summary>True if collection element type has [AcBinarySerializable].</summary>
|
||||
public bool ElementHasGeneratedWriter { get; }
|
||||
/// <summary>True if collection element type implements IId<T>.</summary>
|
||||
public bool ElementIsIId { get; }
|
||||
/// <summary>Generated writer class name for collection element type.</summary>
|
||||
public string? ElementWriterClassName { get; }
|
||||
/// <summary>Id type name for collection element IId types. Null if not IId.</summary>
|
||||
public string? ElementIdTypeName { get; }
|
||||
/// <summary>Collection type: "List", "Array", "IndexedCollection", "Counted", or null (unknown — fallback to runtime).</summary>
|
||||
public string? CollectionKind { get; }
|
||||
/// <summary>Full element type name for generated code (e.g. "SharedTag").</summary>
|
||||
public string? ElementFullTypeName { get; }
|
||||
/// <summary>Add method for Counted concrete collections. null → List<T>.Add(), "Add" → HashSet/SortedSet, "Enqueue" → Queue, "AddLast" → LinkedList.</summary>
|
||||
public string? CollectionAddMethod { get; }
|
||||
/// <summary>True if the concrete Counted collection has a capacity constructor (HashSet, Queue).</summary>
|
||||
public bool CollectionHasCapacityCtor { get; }
|
||||
|
||||
// Dictionary metadata — set when TypeKind == Dictionary
|
||||
/// <summary>Key type kind for dictionary properties.</summary>
|
||||
public PropertyTypeKind DictKeyKind { get; }
|
||||
/// <summary>Value type kind for dictionary properties.</summary>
|
||||
public PropertyTypeKind DictValueKind { get; }
|
||||
/// <summary>Key type name for generated code.</summary>
|
||||
public string? DictKeyTypeName { get; }
|
||||
/// <summary>Value type name for generated code.</summary>
|
||||
public string? DictValueTypeName { get; }
|
||||
/// <summary>True if dictionary value type has [AcBinarySerializable].</summary>
|
||||
public bool DictValueHasGeneratedWriter { get; }
|
||||
/// <summary>Generated writer class name for dictionary value type.</summary>
|
||||
public string? DictValueWriterClassName { get; }
|
||||
/// <summary>True if dictionary value type implements IId<T>.</summary>
|
||||
public bool DictValueIsIId { get; }
|
||||
/// <summary>When false, dict value type skips inline metadata.</summary>
|
||||
public bool DictValueEnableMetadata { get; }
|
||||
/// <summary>FNV-1a hash of dict value type name.</summary>
|
||||
public int DictValueTypeNameHash { get; }
|
||||
/// <summary>FNV-1a hashes of dict value type's properties.</summary>
|
||||
public int[]? DictValuePropertyHashes { get; }
|
||||
/// <summary>When true, dict value subtree has IId types needing scan.</summary>
|
||||
public bool DictValueNeedsIdScan { get; }
|
||||
/// <summary>When true, dict value subtree has non-IId ref tracking.</summary>
|
||||
public bool DictValueNeedsAllRefScan { get; }
|
||||
/// <summary>When true, dict value subtree needs string interning scan.</summary>
|
||||
public bool DictValueNeedsInternScan { get; }
|
||||
/// <summary>Derived: DictValueNeedsIdScan || DictValueNeedsAllRefScan.</summary>
|
||||
public bool DictValueNeedsRefScan => DictValueNeedsIdScan || DictValueNeedsAllRefScan;
|
||||
/// <summary>Derived: any dict value scan axis active.</summary>
|
||||
public bool DictValueNeedsScan => DictValueNeedsIdScan || DictValueNeedsAllRefScan || DictValueNeedsInternScan;
|
||||
|
||||
// UseMetadata inline hash-ek (Complex/Collection child típushoz)
|
||||
/// <summary>FNV-1a hash of child type name (Complex property). Only set when HasGeneratedWriter.</summary>
|
||||
public int ChildTypeNameHash { get; }
|
||||
/// <summary>FNV-1a hashes of child type's properties. Only set when HasGeneratedWriter.</summary>
|
||||
public int[]? ChildPropertyHashes { get; }
|
||||
/// <summary>FNV-1a hash of collection element type name. Only set when ElementHasGeneratedWriter.</summary>
|
||||
public int ElementTypeNameHash { get; }
|
||||
/// <summary>FNV-1a hashes of collection element type's properties. Only set when ElementHasGeneratedWriter.</summary>
|
||||
public int[]? ElementPropertyHashes { get; }
|
||||
/// <summary>When false, child Complex type skips inline metadata in generated code.</summary>
|
||||
public bool ChildEnableMetadata { get; }
|
||||
/// <summary>When false, collection element type skips inline metadata in generated code.</summary>
|
||||
public bool ElementEnableMetadata { get; }
|
||||
/// <summary>When true, child subtree has IId types needing scan (active in OnlyId + All).</summary>
|
||||
public bool ChildNeedsIdScan { get; }
|
||||
/// <summary>When true, child subtree has non-IId ref tracking (active only in All mode).</summary>
|
||||
public bool ChildNeedsAllRefScan { get; }
|
||||
/// <summary>When true, child subtree needs string interning scan.</summary>
|
||||
public bool ChildNeedsInternScan { get; }
|
||||
/// <summary>Derived: ChildNeedsIdScan || ChildNeedsAllRefScan.</summary>
|
||||
public bool ChildNeedsRefScan => ChildNeedsIdScan || ChildNeedsAllRefScan;
|
||||
/// <summary>Derived: any child scan axis active.</summary>
|
||||
public bool ChildNeedsScan => ChildNeedsIdScan || ChildNeedsAllRefScan || ChildNeedsInternScan;
|
||||
/// <summary>When true, element subtree has IId types needing scan (active in OnlyId + All).</summary>
|
||||
public bool ElementNeedsIdScan { get; }
|
||||
/// <summary>When true, element subtree has non-IId ref tracking (active only in All mode).</summary>
|
||||
public bool ElementNeedsAllRefScan { get; }
|
||||
/// <summary>When true, element subtree needs string interning scan.</summary>
|
||||
public bool ElementNeedsInternScan { get; }
|
||||
/// <summary>Derived: ElementNeedsIdScan || ElementNeedsAllRefScan.</summary>
|
||||
public bool ElementNeedsRefScan => ElementNeedsIdScan || ElementNeedsAllRefScan;
|
||||
/// <summary>Derived: any element scan axis active.</summary>
|
||||
public bool ElementNeedsScan => ElementNeedsIdScan || ElementNeedsAllRefScan || ElementNeedsInternScan;
|
||||
|
||||
public PropInfo(string n, string tn, string tnForTypeof, PropertyTypeKind tk, bool nullable,
|
||||
bool isObjectDeclaredType = false,
|
||||
bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null, string? idTypeName = null,
|
||||
PropertyTypeKind elementKind = PropertyTypeKind.Unknown, bool elementHasGenWriter = false, bool elementIsIId = false,
|
||||
string? elementWriterClassName = null, string? elementIdTypeName = null, string? collectionKind = null, string? elementFullTypeName = null,
|
||||
string? collectionAddMethod = null, bool collectionHasCapacityCtor = false,
|
||||
PropertyTypeKind dictKeyKind = PropertyTypeKind.Unknown, PropertyTypeKind dictValueKind = PropertyTypeKind.Unknown,
|
||||
string? dictKeyTypeName = null, string? dictValueTypeName = null,
|
||||
bool dictValueHasGeneratedWriter = false, string? dictValueWriterClassName = null,
|
||||
bool dictValueIsIId = false, bool dictValueEnableMetadata = true,
|
||||
int dictValueTypeNameHash = 0, int[]? dictValuePropertyHashes = null,
|
||||
bool dictValueNeedsIdScan = true, bool dictValueNeedsAllRefScan = true, bool dictValueNeedsInternScan = true,
|
||||
int childTypeNameHash = 0, int[]? childPropertyHashes = null,
|
||||
int elementTypeNameHash = 0, int[]? elementPropertyHashes = null,
|
||||
bool childEnableMetadata = true, bool elementEnableMetadata = true,
|
||||
bool childNeedsIdScan = true, bool childNeedsAllRefScan = true, bool childNeedsInternScan = true,
|
||||
bool elementNeedsIdScan = true, bool elementNeedsAllRefScan = true, bool elementNeedsInternScan = true)
|
||||
{
|
||||
Name = n;
|
||||
TypeName = tn;
|
||||
TypeNameForTypeof = tnForTypeof;
|
||||
TypeKind = tk;
|
||||
IsNullable = nullable;
|
||||
IsObjectDeclaredType = isObjectDeclaredType;
|
||||
HasGeneratedWriter = hasGeneratedWriter;
|
||||
IsIId = isIId;
|
||||
WriterClassName = writerClassName;
|
||||
IdTypeName = idTypeName;
|
||||
ElementKind = elementKind;
|
||||
ElementHasGeneratedWriter = elementHasGenWriter;
|
||||
ElementIsIId = elementIsIId;
|
||||
ElementWriterClassName = elementWriterClassName;
|
||||
ElementIdTypeName = elementIdTypeName;
|
||||
CollectionKind = collectionKind;
|
||||
ElementFullTypeName = elementFullTypeName;
|
||||
CollectionAddMethod = collectionAddMethod;
|
||||
CollectionHasCapacityCtor = collectionHasCapacityCtor;
|
||||
DictKeyKind = dictKeyKind;
|
||||
DictValueKind = dictValueKind;
|
||||
DictKeyTypeName = dictKeyTypeName;
|
||||
DictValueTypeName = dictValueTypeName;
|
||||
DictValueHasGeneratedWriter = dictValueHasGeneratedWriter;
|
||||
DictValueWriterClassName = dictValueWriterClassName;
|
||||
DictValueIsIId = dictValueIsIId;
|
||||
DictValueEnableMetadata = dictValueEnableMetadata;
|
||||
DictValueTypeNameHash = dictValueTypeNameHash;
|
||||
DictValuePropertyHashes = dictValuePropertyHashes;
|
||||
DictValueNeedsIdScan = dictValueNeedsIdScan;
|
||||
DictValueNeedsAllRefScan = dictValueNeedsAllRefScan;
|
||||
DictValueNeedsInternScan = dictValueNeedsInternScan;
|
||||
ChildTypeNameHash = childTypeNameHash;
|
||||
ChildPropertyHashes = childPropertyHashes;
|
||||
ElementTypeNameHash = elementTypeNameHash;
|
||||
ElementPropertyHashes = elementPropertyHashes;
|
||||
ChildEnableMetadata = childEnableMetadata;
|
||||
ElementEnableMetadata = elementEnableMetadata;
|
||||
ChildNeedsIdScan = childNeedsIdScan;
|
||||
ChildNeedsAllRefScan = childNeedsAllRefScan;
|
||||
ChildNeedsInternScan = childNeedsInternScan;
|
||||
ElementNeedsIdScan = elementNeedsIdScan;
|
||||
ElementNeedsAllRefScan = elementNeedsAllRefScan;
|
||||
ElementNeedsInternScan = elementNeedsInternScan;
|
||||
// Mirror runtime _interningFlags computation from BinaryPropertyAccessorBase
|
||||
int flags = 0;
|
||||
if (stringInternAttr == true) flags |= (1 << 1); // Attribute bit
|
||||
if (stringInternAttr != false) flags |= (1 << 2); // All bit
|
||||
InterningFlags = flags;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PropertyTypeKind
|
||||
{
|
||||
Unknown, String, Int32, Int64, Int16, Byte, UInt16, UInt32, UInt64,
|
||||
Boolean, Single, Double, Decimal, DateTime, DateTimeOffset, TimeSpan, Guid, Enum,
|
||||
Collection, Complex, Dictionary,
|
||||
NullableInt32, NullableInt64, NullableInt16, NullableByte, NullableUInt16, NullableUInt32, NullableUInt64,
|
||||
NullableBoolean, NullableSingle, NullableDouble, NullableDecimal, NullableDateTime,
|
||||
NullableDateTimeOffset, NullableTimeSpan, NullableGuid, NullableEnum
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the compile-time decision: does the SGen-emit need a full ref-aware
|
||||
/// switch (<c>Object</c> / <c>ObjectRefFirst</c> / <c>Null</c> / <c>ObjectRef</c> / FixObj) for a
|
||||
/// given Complex property or collection element, OR can it use the zero-branch path
|
||||
/// (<c>Object</c> / <c>Null</c> / FixObj only)?
|
||||
///
|
||||
/// <para><b>Predicate semantics</b>: the decision depends EXCLUSIVELY on whether the child
|
||||
/// element subtree may emit ref markers — captured by <c>PropInfo.ChildNeedsRefScan</c> /
|
||||
/// <c>PropInfo.ElementNeedsRefScan</c>. The parent-level <c>EnableRefHandlingFeature</c> flag is
|
||||
/// <b>NOT</b> a factor here — that flag governs only the parent's SELF-tracking emit in the scan
|
||||
/// pass (<c>GenWriter.cs</c> line 140), it does NOT suppress marker dispatch for child element
|
||||
/// properties of THIS type.</para>
|
||||
///
|
||||
/// <para><b>Writer / reader symmetry</b> — invoked from BOTH sides so the compile-time decision is
|
||||
/// identical at every call site:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GenReader.EmitReadComplex</c> — guards zero-branch vs full ref-aware switch.</item>
|
||||
/// <item><c>GenReader.EmitReadCollectionElement</c> — same guard for collection-element dispatch.</item>
|
||||
/// <item><c>GenReader.EmitReadDictionary</c> — same guard for dictionary-value dispatch.</item>
|
||||
/// <item><c>GenWriter.EmitDirectCollectionWrite</c> — guards <c>Object</c>-only vs
|
||||
/// <c>WriteObjectRefMarker*</c> (runtime decide) emit on the writer side.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Why a generator-only helper, not a runtime helper</b> — the result is inlined into
|
||||
/// the generated code as either the zero-branch ag or the full-switch ag. The predicate runs
|
||||
/// once per emit-site at generation time; the runtime code has zero overhead from this abstraction
|
||||
/// (no method call, no branch on the runtime hot path).</para>
|
||||
///
|
||||
/// <para>Regression target: <c>AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction</c>.</para>
|
||||
/// </summary>
|
||||
internal static class RefAwareEmitPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Reader-side decision for a Complex property (<c>EmitReadComplex</c>) — does the
|
||||
/// emit need a full ref-aware switch on <c>p.ChildNeedsRefScan</c>?
|
||||
/// </summary>
|
||||
internal static bool ChildEmitsRefMarker(PropInfo p) => p.ChildNeedsRefScan;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side decision for a collection element (<c>EmitReadCollectionElement</c>) and
|
||||
/// writer-side decision for the same element (<c>EmitDirectCollectionWrite</c>) — keyed on
|
||||
/// <c>p.ElementNeedsRefScan</c>.
|
||||
/// </summary>
|
||||
internal static bool ElementEmitsRefMarker(PropInfo p) => p.ElementNeedsRefScan;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side overload for <c>EmitReadCollectionElement</c> when only the bool flag is in
|
||||
/// scope (e.g. when <c>PropInfo</c> is unrolled at the call site). Same semantics — kept as
|
||||
/// a thin overload so EVERY call site routes through this predicate, not the raw field.
|
||||
/// </summary>
|
||||
internal static bool ElementEmitsRefMarker(bool elementNeedsRefScan) => elementNeedsRefScan;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side decision for a dictionary value (<c>EmitReadDictionary</c>) — keyed on
|
||||
/// <c>p.DictValueNeedsRefScan</c>. Symmetric with the Complex / Collection-element overloads.
|
||||
/// </summary>
|
||||
internal static bool DictValueEmitsRefMarker(PropInfo p) => p.DictValueNeedsRefScan;
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Type-analysis utilities for the AcBinary source generator: kind detection, FNV-1a hashing,
|
||||
/// symbol enumeration, name flattening, and recursive scan-need computation. All methods are
|
||||
/// pure functions over Roslyn symbols (no mutable state, safe to call from any emit pass).
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true for property types that use markerless serialization in FastMode.
|
||||
/// These types have ExpectedTypeCode at runtime — no type marker byte, no PropertySkip for defaults.
|
||||
/// </summary>
|
||||
private static bool IsMarkerless(PropertyTypeKind k) => k switch
|
||||
{
|
||||
PropertyTypeKind.Int32 or PropertyTypeKind.Int64 or PropertyTypeKind.Int16 or
|
||||
PropertyTypeKind.Byte or PropertyTypeKind.UInt16 or PropertyTypeKind.UInt32 or PropertyTypeKind.UInt64 or
|
||||
PropertyTypeKind.Double or PropertyTypeKind.Single or PropertyTypeKind.Decimal or
|
||||
PropertyTypeKind.DateTime or PropertyTypeKind.Guid or
|
||||
PropertyTypeKind.TimeSpan or PropertyTypeKind.DateTimeOffset or
|
||||
PropertyTypeKind.Boolean or PropertyTypeKind.Enum => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a flat class name for nested types: Outer_Inner_Leaf.
|
||||
/// For top-level types returns the simple name unchanged.
|
||||
/// </summary>
|
||||
private static string BuildFlatName(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol.ContainingType == null)
|
||||
return typeSymbol.Name;
|
||||
|
||||
var parts = new List<string>();
|
||||
var current = typeSymbol;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Add(current.Name);
|
||||
current = current.ContainingType;
|
||||
}
|
||||
parts.Reverse();
|
||||
return string.Join("_", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EnableMetadataFeature from a type's [AcBinarySerializable] attribute.
|
||||
/// Returns true (default) if no attribute or enableAllFeatures=true.
|
||||
/// </summary>
|
||||
private static bool ReadEnableMetadata(ITypeSymbol type)
|
||||
{
|
||||
var attr = type.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (attr == null) return true;
|
||||
if (attr.ConstructorArguments.Length == 1)
|
||||
return (bool)attr.ConstructorArguments[0].Value!;
|
||||
if (attr.ConstructorArguments.Length == 4)
|
||||
return (bool)attr.ConstructorArguments[0].Value!;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes whether a type needs scan pass work, split into ref tracking and string interning.
|
||||
/// Uses a per-call HashSet to guard against circular references (no static cache —
|
||||
/// static state is unsafe in incremental generators as it persists across builds).
|
||||
/// Returns (needsRefScan, needsInternScan) — these are independent axes.
|
||||
/// </summary>
|
||||
private static (bool needsIdScan, bool needsAllRefScan, bool needsInternScan) ComputeNeedsScan(ITypeSymbol type)
|
||||
{
|
||||
return ComputeNeedsScanCore(type, new HashSet<string>());
|
||||
}
|
||||
|
||||
private static (bool needsIdScan, bool needsAllRefScan, bool needsInternScan) ComputeNeedsScanCore(ITypeSymbol type, HashSet<string> visiting)
|
||||
{
|
||||
// Circular reference guard: if already visiting this type, assume true (safe fallback)
|
||||
var key = type.ToDisplayString();
|
||||
if (!visiting.Add(key))
|
||||
return (true, true, true);
|
||||
|
||||
// Read [AcBinarySerializable] flags
|
||||
var attr = type.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
|
||||
bool enableIdTracking = true, enableRefHandling = true, enableInternString = true;
|
||||
if (attr != null)
|
||||
{
|
||||
if (attr.ConstructorArguments.Length == 1)
|
||||
{
|
||||
var all = (bool)attr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = enableRefHandling = enableInternString = all;
|
||||
}
|
||||
else if (attr.ConstructorArguments.Length == 4)
|
||||
{
|
||||
enableIdTracking = (bool)attr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)attr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)attr.ConstructorArguments[3].Value!;
|
||||
}
|
||||
}
|
||||
|
||||
// IId tracking: active in OnlyId + All modes
|
||||
var isIId = enableIdTracking && type.AllInterfaces.Any(i =>
|
||||
i.IsGenericType && i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
var needsIdScan = isIId;
|
||||
// Non-IId ref tracking: active only in All mode
|
||||
var needsAllRefScan = !isIId && enableRefHandling;
|
||||
var needsInternScan = false;
|
||||
|
||||
// Check properties for string interning or complex children
|
||||
foreach (var p in GetAllSerializablePropertySymbols(type))
|
||||
{
|
||||
// Early exit: if all flags are already true, no need to check more properties
|
||||
if (needsIdScan && needsAllRefScan && needsInternScan) break;
|
||||
|
||||
var kind = GetKind(p.Type);
|
||||
|
||||
// String with interning?
|
||||
if (enableInternString && kind == PropertyTypeKind.String)
|
||||
{
|
||||
var internAttr = p.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute");
|
||||
if (internAttr == null || (internAttr.ConstructorArguments.Length == 1 && (bool)internAttr.ConstructorArguments[0].Value!))
|
||||
needsInternScan = true;
|
||||
}
|
||||
|
||||
// Complex child → recurse
|
||||
if (kind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolved = p.Type is INamedTypeSymbol nt ? nt.OriginalDefinition : p.Type;
|
||||
var childFlags = ComputeNeedsScanCore(resolved, visiting);
|
||||
needsIdScan |= childFlags.needsIdScan;
|
||||
needsAllRefScan |= childFlags.needsAllRefScan;
|
||||
needsInternScan |= childFlags.needsInternScan;
|
||||
}
|
||||
|
||||
// Collection → check element type
|
||||
if (kind == PropertyTypeKind.Collection)
|
||||
{
|
||||
var elemType = GetCollectionElementType(p.Type);
|
||||
if (elemType != null)
|
||||
{
|
||||
var elemKind = GetKind(elemType);
|
||||
if (enableInternString && elemKind == PropertyTypeKind.String)
|
||||
needsInternScan = true;
|
||||
if (elemKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedElem = elemType is INamedTypeSymbol ne ? ne.OriginalDefinition : elemType;
|
||||
var elemFlags = ComputeNeedsScanCore(resolvedElem, visiting);
|
||||
needsIdScan |= elemFlags.needsIdScan;
|
||||
needsAllRefScan |= elemFlags.needsAllRefScan;
|
||||
needsInternScan |= elemFlags.needsInternScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dictionary → check key and value types
|
||||
if (kind == PropertyTypeKind.Dictionary)
|
||||
{
|
||||
var (keyType, valueType) = GetDictionaryKeyValueTypes(p.Type);
|
||||
if (keyType != null && enableInternString && GetKind(keyType) == PropertyTypeKind.String)
|
||||
needsInternScan = true;
|
||||
if (valueType != null)
|
||||
{
|
||||
var valKind = GetKind(valueType);
|
||||
if (enableInternString && valKind == PropertyTypeKind.String)
|
||||
needsInternScan = true;
|
||||
if (valKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedVal = valueType is INamedTypeSymbol nv ? nv.OriginalDefinition : valueType;
|
||||
var valFlags = ComputeNeedsScanCore(resolvedVal, visiting);
|
||||
needsIdScan |= valFlags.needsIdScan;
|
||||
needsAllRefScan |= valFlags.needsAllRefScan;
|
||||
needsInternScan |= valFlags.needsInternScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (needsIdScan, needsAllRefScan, needsInternScan);
|
||||
}
|
||||
|
||||
#region FNV-1a Hash (compile-time)
|
||||
|
||||
private static int ComputeFnvHash(string value)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
hash ^= value[i];
|
||||
hash *= 16777619;
|
||||
}
|
||||
return (int)hash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes FNV-1a hashes for all serializable properties of a child type.
|
||||
/// Property filtering and ordering matches runtime TypeMetadataBase exactly:
|
||||
/// derived → base, each level sorted alphabetically, with ignore attribute filtering.
|
||||
/// </summary>
|
||||
private static int[] ComputeChildPropertyHashes(ITypeSymbol resolvedType)
|
||||
{
|
||||
// Use hierarchy-walking helper — order matches runtime TypeMetadataBase
|
||||
var props = GetAllSerializablePropertySymbols(resolvedType);
|
||||
return props.Select(p => ComputeFnvHash(p.Name)).ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Collects all serializable property symbols from the full inheritance hierarchy.
|
||||
/// Order matches runtime TypeMetadataBase.GetUnfilteredProperties exactly:
|
||||
/// derived → base, each level sorted alphabetically by name.
|
||||
/// Filters: public, get+set, non-indexer, non-static, no ignore attributes.
|
||||
/// Deduplicates by name (most-derived override wins).
|
||||
/// </summary>
|
||||
private static List<IPropertySymbol> GetAllSerializablePropertySymbols(ITypeSymbol typeSymbol)
|
||||
{
|
||||
var result = new List<IPropertySymbol>();
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
for (var currentType = typeSymbol as INamedTypeSymbol;
|
||||
currentType != null && currentType.SpecialType != SpecialType.System_Object;
|
||||
currentType = currentType.BaseType)
|
||||
{
|
||||
var levelProps = new List<IPropertySymbol>();
|
||||
|
||||
foreach (var member in currentType.GetMembers())
|
||||
{
|
||||
if (member is IPropertySymbol p &&
|
||||
p.DeclaredAccessibility == Accessibility.Public &&
|
||||
p.GetMethod != null && p.SetMethod != null &&
|
||||
!p.IsIndexer && !p.IsStatic &&
|
||||
seen.Add(p.Name)) // dedup: most-derived wins
|
||||
{
|
||||
var hasIgnore = p.GetAttributes().Any(a =>
|
||||
{
|
||||
var name = a.AttributeClass?.Name ?? "";
|
||||
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
|
||||
});
|
||||
if (hasIgnore) continue;
|
||||
|
||||
levelProps.Add(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each level alphabetically — matches runtime OrderBy(p => p.Name, Ordinal)
|
||||
levelProps.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
result.AddRange(levelProps);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#region Type analysis
|
||||
|
||||
private static bool IsNullableVT(ITypeSymbol t) =>
|
||||
t is INamedTypeSymbol n && n.IsGenericType && n.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
|
||||
|
||||
private static PropertyTypeKind GetKind(ITypeSymbol type)
|
||||
{
|
||||
if (type is INamedTypeSymbol n && n.IsGenericType && n.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T)
|
||||
return GetKindCore(n.TypeArguments[0], true);
|
||||
return GetKindCore(type, false);
|
||||
}
|
||||
|
||||
private static PropertyTypeKind GetKindCore(ITypeSymbol type, bool nullable)
|
||||
{
|
||||
switch (type.SpecialType)
|
||||
{
|
||||
case SpecialType.System_String: return PropertyTypeKind.String;
|
||||
case SpecialType.System_Int32: return nullable ? PropertyTypeKind.NullableInt32 : PropertyTypeKind.Int32;
|
||||
case SpecialType.System_Int64: return nullable ? PropertyTypeKind.NullableInt64 : PropertyTypeKind.Int64;
|
||||
case SpecialType.System_Int16: return nullable ? PropertyTypeKind.NullableInt16 : PropertyTypeKind.Int16;
|
||||
case SpecialType.System_Byte: return nullable ? PropertyTypeKind.NullableByte : PropertyTypeKind.Byte;
|
||||
case SpecialType.System_UInt16: return nullable ? PropertyTypeKind.NullableUInt16 : PropertyTypeKind.UInt16;
|
||||
case SpecialType.System_UInt32: return nullable ? PropertyTypeKind.NullableUInt32 : PropertyTypeKind.UInt32;
|
||||
case SpecialType.System_UInt64: return nullable ? PropertyTypeKind.NullableUInt64 : PropertyTypeKind.UInt64;
|
||||
case SpecialType.System_Boolean: return nullable ? PropertyTypeKind.NullableBoolean : PropertyTypeKind.Boolean;
|
||||
case SpecialType.System_Single: return nullable ? PropertyTypeKind.NullableSingle : PropertyTypeKind.Single;
|
||||
case SpecialType.System_Double: return nullable ? PropertyTypeKind.NullableDouble : PropertyTypeKind.Double;
|
||||
case SpecialType.System_Decimal: return nullable ? PropertyTypeKind.NullableDecimal : PropertyTypeKind.Decimal;
|
||||
case SpecialType.System_DateTime: return nullable ? PropertyTypeKind.NullableDateTime : PropertyTypeKind.DateTime;
|
||||
default: break;
|
||||
}
|
||||
var fn = type.ToDisplayString();
|
||||
if (fn == "System.Guid") return nullable ? PropertyTypeKind.NullableGuid : PropertyTypeKind.Guid;
|
||||
if (fn == "System.TimeSpan") return nullable ? PropertyTypeKind.NullableTimeSpan : PropertyTypeKind.TimeSpan;
|
||||
if (fn == "System.DateTimeOffset") return nullable ? PropertyTypeKind.NullableDateTimeOffset : PropertyTypeKind.DateTimeOffset;
|
||||
if (type.TypeKind == TypeKind.Enum) return nullable ? PropertyTypeKind.NullableEnum : PropertyTypeKind.Enum;
|
||||
if (type is IArrayTypeSymbol) return PropertyTypeKind.Collection;
|
||||
// Dictionary detection: must come before IEnumerable<T> (Dictionary implements both)
|
||||
if (type is INamedTypeSymbol dictNt && dictNt.IsGenericType)
|
||||
{
|
||||
var orig = dictNt.OriginalDefinition.ToDisplayString();
|
||||
if (orig == "System.Collections.Generic.IDictionary<TKey, TValue>" ||
|
||||
orig == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
|
||||
dictNt.AllInterfaces.Any(ifc => ifc.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IDictionary<TKey, TValue>"))
|
||||
return PropertyTypeKind.Dictionary;
|
||||
}
|
||||
if (type is INamedTypeSymbol nt && nt.AllInterfaces.Any(iface => iface.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T))
|
||||
return PropertyTypeKind.Collection;
|
||||
if (type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct) return PropertyTypeKind.Complex;
|
||||
return PropertyTypeKind.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the element type T from List<T>, T[], IList<T>, IEnumerable<T>.
|
||||
/// Returns null if the element type cannot be determined.
|
||||
/// </summary>
|
||||
private static ITypeSymbol? GetCollectionElementType(ITypeSymbol type)
|
||||
{
|
||||
// T[] → element type
|
||||
if (type is IArrayTypeSymbol arrayType)
|
||||
return arrayType.ElementType;
|
||||
|
||||
// Generic collections: List<T>, IList<T>, ICollection<T>, IEnumerable<T>
|
||||
if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
|
||||
{
|
||||
// Direct: List<T>, HashSet<T>, etc. — first type argument
|
||||
var iface = namedType.AllInterfaces
|
||||
.FirstOrDefault(i => i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T);
|
||||
if (iface != null)
|
||||
return iface.TypeArguments[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts key and value types from Dictionary<K,V> or IDictionary<K,V>.
|
||||
/// </summary>
|
||||
private static (ITypeSymbol? keyType, ITypeSymbol? valueType) GetDictionaryKeyValueTypes(ITypeSymbol type)
|
||||
{
|
||||
if (type is INamedTypeSymbol nt && nt.IsGenericType)
|
||||
{
|
||||
var orig = nt.OriginalDefinition.ToDisplayString();
|
||||
if (orig == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
|
||||
orig == "System.Collections.Generic.IDictionary<TKey, TValue>")
|
||||
return (nt.TypeArguments[0], nt.TypeArguments[1]);
|
||||
|
||||
var iface = nt.AllInterfaces.FirstOrDefault(i =>
|
||||
i.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IDictionary<TKey, TValue>");
|
||||
if (iface != null)
|
||||
return (iface.TypeArguments[0], iface.TypeArguments[1]);
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private static bool IsNullableVTKind(PropertyTypeKind k) => k >= PropertyTypeKind.NullableInt32;
|
||||
|
||||
private static PropertyTypeKind Underlying(PropertyTypeKind k) => k switch
|
||||
{
|
||||
PropertyTypeKind.NullableInt32 => PropertyTypeKind.Int32, PropertyTypeKind.NullableInt64 => PropertyTypeKind.Int64,
|
||||
PropertyTypeKind.NullableInt16 => PropertyTypeKind.Int16, PropertyTypeKind.NullableByte => PropertyTypeKind.Byte,
|
||||
PropertyTypeKind.NullableUInt16 => PropertyTypeKind.UInt16, PropertyTypeKind.NullableUInt32 => PropertyTypeKind.UInt32,
|
||||
PropertyTypeKind.NullableUInt64 => PropertyTypeKind.UInt64, PropertyTypeKind.NullableBoolean => PropertyTypeKind.Boolean,
|
||||
PropertyTypeKind.NullableSingle => PropertyTypeKind.Single, PropertyTypeKind.NullableDouble => PropertyTypeKind.Double,
|
||||
PropertyTypeKind.NullableDecimal => PropertyTypeKind.Decimal, PropertyTypeKind.NullableDateTime => PropertyTypeKind.DateTime,
|
||||
PropertyTypeKind.NullableDateTimeOffset => PropertyTypeKind.DateTimeOffset, PropertyTypeKind.NullableTimeSpan => PropertyTypeKind.TimeSpan,
|
||||
PropertyTypeKind.NullableGuid => PropertyTypeKind.Guid, PropertyTypeKind.NullableEnum => PropertyTypeKind.Enum,
|
||||
_ => PropertyTypeKind.Unknown
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
|
@ -12,6 +12,15 @@ Targets **netstandard2.0** (required for Roslyn analyzers/generators).
|
|||
- `ModuleInitializer` — Auto-registers all generated writers/readers at startup.
|
||||
- Circular reference detection with `ACBIN001` diagnostic warning.
|
||||
|
||||
## Slot Allocation
|
||||
|
||||
Each generated writer reserves a unique type slot via `AcBinarySerializer.AllocateWrapperSlot()` (static field initializer, `Interlocked.Increment`).
|
||||
|
||||
- **Slots 0–63** — reserved for runtime polymorphic types (assigned dynamically on first encounter)
|
||||
- **Slots 64+** — source-generated types (allocated at `[ModuleInitializer]` registration time)
|
||||
|
||||
**Slot indices are NOT stable across compilations.** The order depends on Roslyn's `ForAttributeWithMetadataName()` enumeration order, which may vary between builds. This is fine because slots are only meaningful within a single serialization/deserialization session — they are never persisted to disk or sent over the wire as slot indices (the wire format uses type names or metadata hashes for cross-session/cross-type compatibility).
|
||||
|
||||
## Feature Flags
|
||||
|
||||
The `[AcBinarySerializable]` attribute supports per-type feature control:
|
||||
|
|
@ -28,7 +37,3 @@ Disabled features eliminate corresponding code blocks from generated output (zer
|
|||
|---|---|
|
||||
| `Microsoft.CodeAnalysis.CSharp` | Roslyn syntax/semantic APIs |
|
||||
| `Microsoft.CodeAnalysis.Analyzers` | Analyzer best practices |
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
@ -15,4 +15,9 @@
|
|||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="docs\**\*.md" />
|
||||
<None Include="**\README.md" Exclude="$(DefaultItemExcludes);docs\**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
# Loggers
|
||||
|
||||
Provides a singleton `GlobalLogger` for application-wide logging with multiple severity levels (Detail, Debug, Info, Warning, Suggest, Error) and support for pluggable log writers.
|
||||
Server-side singleton logger for static access across the application.
|
||||
|
||||
> For full logging architecture see `docs/LOGGING/README.md`. For core logger and writer abstractions see `AyCode.Core/Loggers/README.md`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`GlobalLogger.cs`** — Singleton static logger that delegates to `AcLoggerBase`. Supports category names, caller member tracking, and configurable `LogLevel` and `AppType`.
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
- **`GlobalLogger.cs`** — Singleton static wrapper around an internal `AcGlobalLoggerBase` (sealed `AcLoggerBase` subclass). Provides static methods for all log levels (`Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`). Default category: `"GLOBAL_LOGGER"`. Reads config from `appsettings.json` like any `AcLoggerBase`. Exposes `GetWriters` and `Writer<T>()` for accessing specific writer instances.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
# AyCode.Core.Server
|
||||
|
||||
@project {
|
||||
type = "framework"
|
||||
}
|
||||
|
||||
Server-side extension of AyCode.Core. Provides server-specific implementations that build on the shared core library.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Topic |
|
||||
|---|---|
|
||||
| `LOGGING/README.md` | GlobalLogger singleton, server-side logging |
|
||||
|
||||
## Folder Structure
|
||||
|
||||
| Folder | Purpose |
|
||||
|
|
@ -21,7 +31,3 @@ Server-side extension of AyCode.Core. Provides server-specific implementations t
|
|||
| `MessagePack` | MessagePack serialization |
|
||||
| `Newtonsoft.Json` | JSON serialization |
|
||||
| `Microsoft.Extensions.Logging.Abstractions` | Logging abstractions |
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# Server Logging
|
||||
|
||||
Server-side logging extensions. Core framework (base classes, config, LogLevel, ILogger bridge): `AyCode.Core/AyCode.Core/docs/LOGGING/README.md` | Remote writers (HTTP, browser, SignalR): `AyCode.Services/docs/LOGGING/README.md`.
|
||||
|
||||
## GlobalLogger
|
||||
|
||||
Server-side singleton for static access. Wraps an internal `AcGlobalLoggerBase` instance (sealed `AcLoggerBase` subclass):
|
||||
|
||||
```csharp
|
||||
GlobalLogger.Info("Server started");
|
||||
GlobalLogger.Error("Failed to process", ex, "MyCategory");
|
||||
GlobalLogger.Writer<IAcConsoleLogWriter>()?.Suggest("hint");
|
||||
```
|
||||
|
||||
Default category: `"GLOBAL_LOGGER"`. Reads config from `appsettings.json` like any other `AcLoggerBase` instance.
|
||||
|
||||
All static methods mirror the `IAcLogWriterBase` contract: `Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`.
|
||||
|
||||
## Key Source Files
|
||||
|
||||
| Component | Path |
|
||||
|-----------|------|
|
||||
| GlobalLogger | `Loggers/GlobalLogger.cs` |
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# AyCode.Core.Server documentation
|
||||
|
||||
Topic docs for the `AyCode.Core.Server` project (Layer 0, server-side).
|
||||
|
||||
## Topics
|
||||
|
||||
- [`LOGGING/`](LOGGING/README.md) — Server-side logger (variant of AyCode.Core's base logger)
|
||||
|
||||
## Navigation
|
||||
|
||||
Per the folder-navigation rule, start here when browsing `docs/`. Each topic folder has its own `README.md` (main content) + optional `TOPIC_ISSUES.md` and `TOPIC_TODO.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- **Base logger** (framework): `../../AyCode.Core/AyCode.Core/docs/LOGGING/README.md`
|
||||
- **Remote logger** (AyCode.Services variant): `../../AyCode.Services/docs/LOGGING/README.md`
|
||||
|
|
@ -16,7 +16,3 @@ Concrete entity implementations inheriting from AyCode.Entities abstract generic
|
|||
## Relationships
|
||||
|
||||
User ↔ Company (many-to-many via UserToCompany), User → Profile → Address (one-to-one chain), EmailMessage → EmailRecipient (one-to-many).
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,3 @@ Concrete entity implementations for database integration testing. Exposes types
|
|||
| `MSTest` | Test framework |
|
||||
| `AyCode.Core.Tests` | Shared test utilities |
|
||||
| `AyCode.Entities` / `AyCode.Entities.Server` | Abstract entity base classes |
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,3 @@ GZip compression utility tests.
|
|||
## Key Files
|
||||
|
||||
- **`GzipHelperTests.cs`** — Tests GzipHelper.Compress(), DecompressToString(), DecompressToRentedBuffer() (ArrayPool), IsGzipCompressed() (magic byte detection).
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,3 @@ Hand-written examples of the code pattern that the AcBinarySerializable source g
|
|||
## Key Files
|
||||
|
||||
- **`TestOrderWriter.cs`** — Example IGeneratedBinaryWriter: direct property access (no reflection), alphabetical order, value types inline, complex types delegate to runtime. Demonstrates ICache-friendly pattern (~500B vs 27KB runtime).
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using AyCode.Core.Tests.TestModels;
|
|||
namespace AyCode.Core.Tests.GeneratedWriters;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-written generated binary writer for TestOrder.
|
||||
/// Hand-written generated binary writer for TestOrder_All_True.
|
||||
/// Demonstrates the pattern that the source generator will produce.
|
||||
///
|
||||
/// Bypasses the runtime switch/delegate property loop:
|
||||
|
|
@ -21,22 +21,19 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
|
|||
{
|
||||
internal static readonly TestOrderWriter Instance = new();
|
||||
|
||||
public void WriteProperties<TOutput>(
|
||||
object value,
|
||||
AcBinarySerializer.BinarySerializationContext<TOutput> context,
|
||||
int depth)
|
||||
public void WriteProperties<TOutput>(object value,
|
||||
AcBinarySerializer.BinarySerializationContext<TOutput> context)
|
||||
where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
var obj = Unsafe.As<TestOrder>(value);
|
||||
var nextDepth = depth;
|
||||
var obj = Unsafe.As<TestOrder_All_True>(value);
|
||||
|
||||
// Properties in alphabetical order (matching runtime serializer):
|
||||
|
||||
// AuditMetadata: MetadataInfo? (complex, nullable)
|
||||
WriteComplexOrNull(obj.AuditMetadata, context, nextDepth);
|
||||
// AuditMetadata: MetadataInfo_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.AuditMetadata, context);
|
||||
|
||||
// Category: SharedCategory? (complex, nullable)
|
||||
WriteComplexOrNull(obj.Category, context, nextDepth);
|
||||
// Category: SharedCategory_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.Category, context);
|
||||
|
||||
// CreatedAt: DateTime (markerless)
|
||||
context.WriteDateTimeBits(obj.CreatedAt);
|
||||
|
|
@ -44,23 +41,23 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
|
|||
// Id: int (markerless)
|
||||
context.WriteVarInt(obj.Id);
|
||||
|
||||
// Items: List<TestOrderItem> (collection)
|
||||
WriteComplexOrNull(obj.Items, context, nextDepth);
|
||||
// Items: List<TestOrderItem_All_True> (collection)
|
||||
WriteComplexOrNull(obj.Items, context);
|
||||
|
||||
// MetadataList: List<MetadataInfo> (collection)
|
||||
WriteComplexOrNull(obj.MetadataList, context, nextDepth);
|
||||
// MetadataList: List<MetadataInfo_All_True> (collection)
|
||||
WriteComplexOrNull(obj.MetadataList, context);
|
||||
|
||||
// NoMergeItems: List<TestOrderItem> (collection)
|
||||
WriteComplexOrNull(obj.NoMergeItems, context, nextDepth);
|
||||
// NoMergeItems: List<TestOrderItem_All_True> (collection)
|
||||
WriteComplexOrNull(obj.NoMergeItems, context);
|
||||
|
||||
// OrderMetadata: MetadataInfo? (complex, nullable)
|
||||
WriteComplexOrNull(obj.OrderMetadata, context, nextDepth);
|
||||
// OrderMetadata: MetadataInfo_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.OrderMetadata, context);
|
||||
|
||||
// OrderNumber: string
|
||||
AcBinarySerializer.WriteStringGenerated(obj.OrderNumber, context);
|
||||
|
||||
// Owner: SharedUser? (complex, nullable)
|
||||
WriteComplexOrNull(obj.Owner, context, nextDepth);
|
||||
WriteComplexOrNull(obj.Owner, context);
|
||||
|
||||
// PaidDateUtc: DateTime? (nullable)
|
||||
var paidDate = obj.PaidDateUtc;
|
||||
|
|
@ -74,23 +71,23 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
|
|||
context.WriteByte(BinaryTypeCode.Null);
|
||||
}
|
||||
|
||||
// PrimaryTag: SharedTag? (complex, nullable)
|
||||
WriteComplexOrNull(obj.PrimaryTag, context, nextDepth);
|
||||
// PrimaryTag: SharedTag_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.PrimaryTag, context);
|
||||
|
||||
// SecondaryTag: SharedTag? (complex, nullable)
|
||||
WriteComplexOrNull(obj.SecondaryTag, context, nextDepth);
|
||||
// SecondaryTag: SharedTag_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.SecondaryTag, context);
|
||||
|
||||
// Status: TestStatus (enum, markerless)
|
||||
context.WriteVarInt((int)obj.Status);
|
||||
|
||||
// Tags: List<SharedTag> (collection)
|
||||
WriteComplexOrNull(obj.Tags, context, nextDepth);
|
||||
// Tags: List<SharedTag_All_True> (collection)
|
||||
WriteComplexOrNull(obj.Tags, context);
|
||||
|
||||
// TotalAmount: decimal (markerless)
|
||||
context.WriteDecimalBits(obj.TotalAmount);
|
||||
}
|
||||
|
||||
public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth) where TOutput : struct, IBinaryOutputBase
|
||||
public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
|
@ -98,12 +95,12 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
|
|||
public void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
if (!context.HasCaching) return;
|
||||
ScanObject(value, context, 0);
|
||||
ScanObject(value, context);
|
||||
context.SortWritePlan();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
|
||||
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context)
|
||||
where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
if (value == null)
|
||||
|
|
@ -112,6 +109,6 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
|
|||
return;
|
||||
}
|
||||
|
||||
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context, depth);
|
||||
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ public sealed class JsonExtensionTests
|
|||
public void SemanticReference_SharedTag_SerializesWithSemanticId()
|
||||
{
|
||||
// Arrange
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var order = TestDataFactory.CreateOrder(itemCount: 2, sharedTag: sharedTag);
|
||||
|
||||
// Act
|
||||
|
|
@ -133,7 +133,7 @@ public sealed class JsonExtensionTests
|
|||
{
|
||||
// Arrange
|
||||
var sharedTag = TestDataFactory.CreateTag("OriginalKey");
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
|
|
@ -183,19 +183,19 @@ public sealed class JsonExtensionTests
|
|||
public void NewtonsoftReference_DeepNestedNonId_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var rootMeta = new MetadataInfo
|
||||
var rootMeta = new MetadataInfo_All_True
|
||||
{
|
||||
Key = "Root",
|
||||
Value = "RootValue",
|
||||
ChildMetadata = new MetadataInfo
|
||||
ChildMetadata = new MetadataInfo_All_True
|
||||
{
|
||||
Key = "Child",
|
||||
Value = "ChildValue",
|
||||
ChildMetadata = new MetadataInfo { Key = "GrandChild", Value = "GrandChildValue" }
|
||||
ChildMetadata = new MetadataInfo_All_True { Key = "GrandChild", Value = "GrandChildValue" }
|
||||
}
|
||||
};
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
|
|
@ -225,7 +225,7 @@ public sealed class JsonExtensionTests
|
|||
var sharedMeta = TestDataFactory.CreateMetadata();
|
||||
sharedTag.Description = sharedMeta.Key; // Link them
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
|
|
@ -234,7 +234,7 @@ public sealed class JsonExtensionTests
|
|||
OrderMetadata = sharedMeta,
|
||||
AuditMetadata = sharedMeta,
|
||||
Tags = [sharedTag],
|
||||
Items = [new TestOrderItem { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }]
|
||||
Items = [new TestOrderItem_All_True { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }]
|
||||
};
|
||||
|
||||
var json = order.ToJson();
|
||||
|
|
@ -254,8 +254,8 @@ public sealed class JsonExtensionTests
|
|||
// Arrange
|
||||
var order = TestDataFactory.CreateOrder(itemCount: 1);
|
||||
order.NoMergeItems = [
|
||||
new TestOrderItem { Id = 100, ProductName = "NoMerge-A" },
|
||||
new TestOrderItem { Id = 101, ProductName = "NoMerge-B" }
|
||||
new TestOrderItem_All_True { Id = 100, ProductName = "NoMerge-A" },
|
||||
new TestOrderItem_All_True { Id = 101, ProductName = "NoMerge-B" }
|
||||
];
|
||||
|
||||
var originalRef = order.NoMergeItems;
|
||||
|
|
@ -284,13 +284,13 @@ public sealed class JsonExtensionTests
|
|||
public void NonIdCollection_ReplacesContent()
|
||||
{
|
||||
// Arrange
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
MetadataList = [
|
||||
new MetadataInfo { Key = "Old-A" },
|
||||
new MetadataInfo { Key = "Old-B" }
|
||||
new MetadataInfo_All_True { Key = "Old-A" },
|
||||
new MetadataInfo_All_True { Key = "Old-B" }
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ public sealed class JsonExtensionTests
|
|||
|
||||
// Act
|
||||
var json = order.ToJson();
|
||||
var deserialized = json.JsonTo<TestOrder>();
|
||||
var deserialized = json.JsonTo<TestOrder_All_True>();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(deserialized);
|
||||
|
|
@ -442,7 +442,7 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void WasmCompat_AcJsonSerializer_SimpleObject()
|
||||
{
|
||||
var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 99.99m, Status = TestStatus.Processing };
|
||||
var item = new TestOrderItem_All_True { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 99.99m, Status = TestStatus.Processing };
|
||||
|
||||
var json = AcJsonSerializer.Serialize(item);
|
||||
|
||||
|
|
@ -453,10 +453,10 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void WasmCompat_AcJsonDeserializer_RoundTrip()
|
||||
{
|
||||
var original = new TestOrderItem { Id = 42, ProductName = "WASM Test", Quantity = 5, UnitPrice = 25.50m, Status = TestStatus.Shipped };
|
||||
var original = new TestOrderItem_All_True { Id = 42, ProductName = "WASM Test", Quantity = 5, UnitPrice = 25.50m, Status = TestStatus.Shipped };
|
||||
var json = AcJsonSerializer.Serialize(original);
|
||||
|
||||
var deserialized = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
|
||||
var deserialized = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
|
||||
|
||||
Assert.IsNotNull(deserialized);
|
||||
Assert.AreEqual(42, deserialized.Id);
|
||||
|
|
@ -484,14 +484,14 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void WasmCompat_EmptyCollections_HandleCorrectly()
|
||||
{
|
||||
var order = new TestOrder { Id = 1, OrderNumber = "EMPTY-TEST", Items = [], Tags = [] };
|
||||
var order = new TestOrder_All_True { Id = 1, OrderNumber = "EMPTY-TEST", Items = [], Tags = [] };
|
||||
|
||||
var json = AcJsonSerializer.Serialize(order);
|
||||
|
||||
Assert.IsTrue(json.Contains("\"Items\":[]"), "Empty Items should serialize as []");
|
||||
Assert.IsTrue(json.Contains("\"Tags\":[]"), "Empty Tags should serialize as []");
|
||||
|
||||
var deserialized = AcJsonDeserializer.Deserialize<TestOrder>(json);
|
||||
var deserialized = AcJsonDeserializer.Deserialize<TestOrder_All_True>(json);
|
||||
|
||||
Assert.IsNotNull(deserialized?.Items);
|
||||
Assert.AreEqual(0, deserialized.Items.Count);
|
||||
|
|
@ -513,8 +513,8 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void WasmCompat_SharedReferences_IdRefResolution()
|
||||
{
|
||||
var sharedTag = new SharedTag { Id = 999, Name = "SharedKey" };
|
||||
var order = new TestOrder { Id = 1, OrderNumber = "REF-TEST", PrimaryTag = sharedTag, SecondaryTag = sharedTag, Tags = [sharedTag] };
|
||||
var sharedTag = new SharedTag_All_True { Id = 999, Name = "SharedKey" };
|
||||
var order = new TestOrder_All_True { Id = 1, OrderNumber = "REF-TEST", PrimaryTag = sharedTag, SecondaryTag = sharedTag, Tags = [sharedTag] };
|
||||
|
||||
var json = AcJsonSerializer.Serialize(order);
|
||||
|
||||
|
|
@ -528,7 +528,7 @@ public sealed class JsonExtensionTests
|
|||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
var deserialized = JsonConvert.DeserializeObject<TestOrder>(json, nativeSettings);
|
||||
var deserialized = JsonConvert.DeserializeObject<TestOrder_All_True>(json, nativeSettings);
|
||||
|
||||
Assert.IsNotNull(deserialized);
|
||||
Assert.AreSame(deserialized.PrimaryTag, deserialized.SecondaryTag);
|
||||
|
|
@ -543,10 +543,10 @@ public sealed class JsonExtensionTests
|
|||
public void CrossSerializer_MixedReferences_CompatibleWithNewtonsoft()
|
||||
{
|
||||
// Arrange
|
||||
var sharedTag = new SharedTag { Id = 100, Name = "SharedKey", CreatedAt = DateTime.UtcNow };
|
||||
var sharedMeta = new MetadataInfo { Key = "SharedMeta", Value = "MetaValue", ChildMetadata = new MetadataInfo { Key = "Child" } };
|
||||
var sharedTag = new SharedTag_All_True { Id = 100, Name = "SharedKey", CreatedAt = DateTime.UtcNow };
|
||||
var sharedMeta = new MetadataInfo_All_True { Key = "SharedMeta", Value = "MetaValue", ChildMetadata = new MetadataInfo_All_True { Key = "Child" } };
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
|
|
@ -556,7 +556,7 @@ public sealed class JsonExtensionTests
|
|||
OrderMetadata = sharedMeta,
|
||||
AuditMetadata = sharedMeta,
|
||||
Tags = [sharedTag],
|
||||
Items = [new TestOrderItem { Id = 10, ProductName = "Product-A", Tag = sharedTag, ItemMetadata = sharedMeta }]
|
||||
Items = [new TestOrderItem_All_True { Id = 10, ProductName = "Product-A", Tag = sharedTag, ItemMetadata = sharedMeta }]
|
||||
};
|
||||
|
||||
// Act - Serialize with AyCode
|
||||
|
|
@ -570,7 +570,7 @@ public sealed class JsonExtensionTests
|
|||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
var deserialized = JsonConvert.DeserializeObject<TestOrder>(json, nativeSettings);
|
||||
var deserialized = JsonConvert.DeserializeObject<TestOrder_All_True>(json, nativeSettings);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(deserialized);
|
||||
|
|
@ -589,11 +589,11 @@ public sealed class JsonExtensionTests
|
|||
var json = @"{
|
||||
""Id"": 1,
|
||||
""OrderNumber"": ""ORD-001"",
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" },
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" },
|
||||
""SecondaryTag"": { ""$ref"": ""1"" }
|
||||
}";
|
||||
|
||||
var order = new TestOrder { Id = 1, OrderNumber = "OLD" };
|
||||
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD" };
|
||||
|
||||
// Act
|
||||
json.JsonTo(order);
|
||||
|
|
@ -602,7 +602,7 @@ public sealed class JsonExtensionTests
|
|||
Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set");
|
||||
Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from $ref");
|
||||
Assert.AreEqual(100, order.PrimaryTag.Id);
|
||||
Assert.AreEqual("SharedTag", order.PrimaryTag.Name);
|
||||
Assert.AreEqual("SharedTag_All_True", order.PrimaryTag.Name);
|
||||
Assert.AreSame(order.PrimaryTag, order.SecondaryTag,
|
||||
"SecondaryTag should reference the same object as PrimaryTag via $ref");
|
||||
}
|
||||
|
|
@ -613,14 +613,14 @@ public sealed class JsonExtensionTests
|
|||
var json = @"{
|
||||
""Id"": 1,
|
||||
""OrderNumber"": ""ORD-001"",
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" },
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" },
|
||||
""Tags"": [
|
||||
{ ""$ref"": ""1"" },
|
||||
{ ""$id"": ""2"", ""Id"": 200, ""Name"": ""OtherTag"" }
|
||||
]
|
||||
}";
|
||||
|
||||
var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() };
|
||||
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag_All_True>() };
|
||||
|
||||
// Act
|
||||
json.JsonTo(order);
|
||||
|
|
@ -648,11 +648,11 @@ public sealed class JsonExtensionTests
|
|||
""PrimaryTag"": { ""$ref"": ""1"" }
|
||||
}";
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "OLD",
|
||||
Items = new List<TestOrderItem> { new TestOrderItem { Id = 10, ProductName = "OLD" } }
|
||||
Items = new List<TestOrderItem_All_True> { new TestOrderItem_All_True { Id = 10, ProductName = "OLD" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -672,10 +672,10 @@ public sealed class JsonExtensionTests
|
|||
""Id"": 1,
|
||||
""OrderNumber"": ""ORD-001"",
|
||||
""SecondaryTag"": { ""$ref"": ""1"" },
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" }
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" }
|
||||
}";
|
||||
|
||||
var order = new TestOrder { Id = 1, OrderNumber = "OLD" };
|
||||
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD" };
|
||||
|
||||
// Act
|
||||
json.JsonTo(order);
|
||||
|
|
@ -693,7 +693,7 @@ public sealed class JsonExtensionTests
|
|||
var json = @"{
|
||||
""Id"": 1,
|
||||
""OrderNumber"": ""ORD-001"",
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" },
|
||||
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" },
|
||||
""SecondaryTag"": { ""$ref"": ""1"" },
|
||||
""Tags"": [
|
||||
{ ""$ref"": ""1"" },
|
||||
|
|
@ -702,7 +702,7 @@ public sealed class JsonExtensionTests
|
|||
]
|
||||
}";
|
||||
|
||||
var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() };
|
||||
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag_All_True>() };
|
||||
|
||||
// Act
|
||||
json.JsonTo(order);
|
||||
|
|
@ -731,10 +731,10 @@ public sealed class JsonExtensionTests
|
|||
""PrimaryTag"": { ""$ref"": ""deep1"" }
|
||||
}";
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
Items = new List<TestOrderItem> { new TestOrderItem { Id = 10 } }
|
||||
Items = new List<TestOrderItem_All_True> { new TestOrderItem_All_True { Id = 10 } }
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -762,10 +762,10 @@ public sealed class JsonExtensionTests
|
|||
}]
|
||||
}";
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
Items = new List<TestOrderItem> { new TestOrderItem { Id = 10 } }
|
||||
Items = new List<TestOrderItem_All_True> { new TestOrderItem_All_True { Id = 10 } }
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -789,7 +789,7 @@ public sealed class JsonExtensionTests
|
|||
}";
|
||||
|
||||
// Act
|
||||
var order = AcJsonDeserializer.Deserialize<TestOrder>(json);
|
||||
var order = AcJsonDeserializer.Deserialize<TestOrder_All_True>(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(order);
|
||||
|
|
@ -820,7 +820,7 @@ public sealed class JsonExtensionTests
|
|||
}";
|
||||
|
||||
// Act
|
||||
var order = AcJsonDeserializer.Deserialize<TestOrder>(json);
|
||||
var order = AcJsonDeserializer.Deserialize<TestOrder_All_True>(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(order);
|
||||
|
|
@ -841,8 +841,8 @@ public sealed class JsonExtensionTests
|
|||
""SecondaryTag"": { ""$ref"": ""1"" }
|
||||
}";
|
||||
|
||||
var existingTag = new SharedTag { Id = 999, Name = "ExistingTag" };
|
||||
var order = new TestOrder
|
||||
var existingTag = new SharedTag_All_True { Id = 999, Name = "ExistingTag" };
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
SecondaryTag = existingTag
|
||||
|
|
@ -1047,9 +1047,9 @@ public sealed class JsonExtensionTests
|
|||
{
|
||||
Id = 1,
|
||||
Name = "Test",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag" }
|
||||
};
|
||||
// Using existing Tag property with Guid in SharedTag's CreatedAt
|
||||
// Using existing Tag property with Guid in SharedTag_All_True's CreatedAt
|
||||
|
||||
var json = AcJsonSerializer.Serialize(obj);
|
||||
|
||||
|
|
@ -1240,8 +1240,8 @@ public sealed class JsonExtensionTests
|
|||
public void Populate_ObjectToObject_PopulatesProperties()
|
||||
{
|
||||
var json = "{\"Name\": \"Updated\", \"Id\": 99}";
|
||||
var obj = new SharedTag { Id = 1, Name = "Original" };
|
||||
AcJsonDeserializer.Populate(json, obj, typeof(SharedTag));
|
||||
var obj = new SharedTag_All_True { Id = 1, Name = "Original" };
|
||||
AcJsonDeserializer.Populate(json, obj, typeof(SharedTag_All_True));
|
||||
Assert.AreEqual(99, obj.Id);
|
||||
Assert.AreEqual("Updated", obj.Name);
|
||||
}
|
||||
|
|
@ -1249,14 +1249,14 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void Deserialize_NullJson_ReturnsDefault()
|
||||
{
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem>("null");
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>("null");
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_EmptyJson_ReturnsDefault()
|
||||
{
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem>("");
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>("");
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
|
|
@ -1312,7 +1312,7 @@ public sealed class JsonExtensionTests
|
|||
|
||||
try
|
||||
{
|
||||
AcJsonDeserializer.Deserialize<TestOrderItem>(invalidJson);
|
||||
AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(invalidJson);
|
||||
Assert.Fail("Expected AcJsonDeserializationException");
|
||||
}
|
||||
catch (AcJsonDeserializationException)
|
||||
|
|
@ -1329,7 +1329,7 @@ public sealed class JsonExtensionTests
|
|||
|
||||
try
|
||||
{
|
||||
AcJsonDeserializer.Deserialize<TestOrderItem>(doubleQuotedJson);
|
||||
AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(doubleQuotedJson);
|
||||
Assert.Fail("Expected AcJsonDeserializationException for double-serialized JSON");
|
||||
}
|
||||
catch (AcJsonDeserializationException ex)
|
||||
|
|
@ -1346,7 +1346,7 @@ public sealed class JsonExtensionTests
|
|||
|
||||
try
|
||||
{
|
||||
AcJsonDeserializer.Deserialize<TestOrderItem>(arrayJson);
|
||||
AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(arrayJson);
|
||||
Assert.Fail("Expected AcJsonDeserializationException");
|
||||
}
|
||||
catch (AcJsonDeserializationException ex)
|
||||
|
|
@ -1363,7 +1363,7 @@ public sealed class JsonExtensionTests
|
|||
|
||||
try
|
||||
{
|
||||
AcJsonDeserializer.Deserialize<List<TestOrderItem>>(objectJson);
|
||||
AcJsonDeserializer.Deserialize<List<TestOrderItem_All_True>>(objectJson);
|
||||
Assert.Fail("Expected AcJsonDeserializationException");
|
||||
}
|
||||
catch (AcJsonDeserializationException ex)
|
||||
|
|
@ -1376,7 +1376,7 @@ public sealed class JsonExtensionTests
|
|||
public void Populate_NullTarget_ThrowsArgumentNullException()
|
||||
{
|
||||
var json = "{\"Id\":1}";
|
||||
TestOrderItem? target = null;
|
||||
TestOrderItem_All_True? target = null;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -1392,7 +1392,7 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void Populate_InvalidJson_ThrowsException()
|
||||
{
|
||||
var target = new TestOrderItem();
|
||||
var target = new TestOrderItem_All_True();
|
||||
var invalidJson = "{ not valid }";
|
||||
|
||||
try
|
||||
|
|
@ -1409,7 +1409,7 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void Populate_ArrayToNonList_ThrowsException()
|
||||
{
|
||||
var target = new TestOrderItem();
|
||||
var target = new TestOrderItem_All_True();
|
||||
var arrayJson = "[1,2,3]";
|
||||
|
||||
try
|
||||
|
|
@ -1432,7 +1432,7 @@ public sealed class JsonExtensionTests
|
|||
{
|
||||
var json = "{\"Id\":1,\"ProductName\":\"Test \\\"quoted\\\" and \\\\backslash\"}";
|
||||
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Test \"quoted\" and \\backslash", result.ProductName);
|
||||
|
|
@ -1443,7 +1443,7 @@ public sealed class JsonExtensionTests
|
|||
{
|
||||
var json = "{\"Id\":1,\"ProductName\":\"中文日本語한국어🎉\"}";
|
||||
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("中文日本語한국어🎉", result.ProductName);
|
||||
|
|
@ -1454,7 +1454,7 @@ public sealed class JsonExtensionTests
|
|||
{
|
||||
var json = "{\"Id\":999999999,\"ProductName\":\"Big\",\"Quantity\":2147483647}";
|
||||
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(999999999, result.Id);
|
||||
|
|
@ -1464,7 +1464,7 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void Serialize_ThenDeserialize_RoundTripPreservesData()
|
||||
{
|
||||
var original = new TestOrderItem
|
||||
var original = new TestOrderItem_All_True
|
||||
{
|
||||
Id = 42,
|
||||
ProductName = "Test with \"quotes\" and \\backslash",
|
||||
|
|
@ -1474,7 +1474,7 @@ public sealed class JsonExtensionTests
|
|||
};
|
||||
|
||||
var json = original.ToJson();
|
||||
var restored = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
|
||||
var restored = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
|
||||
|
||||
Assert.IsNotNull(restored);
|
||||
Assert.AreEqual(original.Id, restored.Id);
|
||||
|
|
@ -1491,12 +1491,12 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void Deserialize_TaskWrappedJson_DirectDeserialization_OnlyGetsRootProperties()
|
||||
{
|
||||
// This JSON represents a serialized Task<TestOrderItem> - the actual data is in "Result"
|
||||
// This JSON represents a serialized Task<TestOrderItem_All_True> - the actual data is in "Result"
|
||||
// This happens when someone forgets to await an async method before serializing
|
||||
var taskWrappedJson = "{\"Result\":{\"Id\":1,\"ProductName\":\"Processed: TestProduct\",\"Quantity\":10,\"UnitPrice\":20,\"TotalPrice\":200},\"Id\":1,\"Status\":5,\"IsCompleted\":true,\"IsCompletedSuccessfully\":true}";
|
||||
|
||||
// Direct deserialization to TestOrderItem only gets root-level properties
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(taskWrappedJson);
|
||||
// Direct deserialization to TestOrderItem_All_True only gets root-level properties
|
||||
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(taskWrappedJson);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
// Id=1 is at root level and matches
|
||||
|
|
@ -1509,11 +1509,11 @@ public sealed class JsonExtensionTests
|
|||
[TestMethod]
|
||||
public void Deserialize_TaskWrappedJson_UseWrapperClass_ExtractsCorrectly()
|
||||
{
|
||||
// This JSON represents a serialized Task<TestOrderItem> - the actual data is in "Result"
|
||||
// This JSON represents a serialized Task<TestOrderItem_All_True> - the actual data is in "Result"
|
||||
var taskWrappedJson = "{\"Result\":{\"Id\":1,\"ProductName\":\"Processed: TestProduct\",\"Quantity\":10,\"UnitPrice\":20,\"TotalPrice\":200},\"Id\":1,\"Status\":5,\"IsCompleted\":true,\"IsCompletedSuccessfully\":true}";
|
||||
|
||||
// Proper approach: deserialize to a wrapper type and extract Result
|
||||
var wrapper = AcJsonDeserializer.Deserialize<TaskResultWrapper<TestOrderItem>>(taskWrappedJson);
|
||||
var wrapper = AcJsonDeserializer.Deserialize<TaskResultWrapper<TestOrderItem_All_True>>(taskWrappedJson);
|
||||
|
||||
Assert.IsNotNull(wrapper);
|
||||
Assert.IsNotNull(wrapper.Result);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,3 @@ MSTest unit tests for AyCode.Core serialization, compression, and utilities. Cov
|
|||
| `MessagePack` | Serialization comparison |
|
||||
| `MemoryPack` | Serialization comparison |
|
||||
| `MongoDB.Bson` | BSON comparison |
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ public class AcBinarySerializerBenchmarkTests
|
|||
var binary = AcBinarySerializer.Serialize(order);
|
||||
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
|
||||
|
|
@ -29,7 +29,7 @@ public class AcBinarySerializerBenchmarkTests
|
|||
var binary = AcBinarySerializer.Serialize(order);
|
||||
Assert.IsTrue(binary.Length > 0);
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ public class AcBinarySerializerBenchmarkTests
|
|||
var binary = AcBinarySerializer.Serialize(order);
|
||||
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
|
||||
|
|
@ -69,8 +69,8 @@ public class AcBinarySerializerBenchmarkTests
|
|||
Console.WriteLine($"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
|
||||
|
||||
// Both should deserialize correctly regardless of size
|
||||
var result1 = AcBinaryDeserializer.Deserialize<TestOrder>(binaryWithInterning);
|
||||
var result2 = AcBinaryDeserializer.Deserialize<TestOrder>(binaryWithoutInterning);
|
||||
var result1 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithInterning);
|
||||
var result2 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithoutInterning);
|
||||
|
||||
Assert.IsNotNull(result1);
|
||||
Assert.IsNotNull(result2);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public class AcBinarySerializerChainReferenceTests
|
|||
public void ChainPopulate_IIdObjects_PreservesReferences()
|
||||
{
|
||||
// Setup: Create internal cache with 5 categories
|
||||
var internalCache = new List<SharedCategory>
|
||||
var internalCache = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1 },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2 },
|
||||
|
|
@ -30,7 +30,7 @@ public class AcBinarySerializerChainReferenceTests
|
|||
};
|
||||
|
||||
// Server returns subset of categories (like grid pagination - page 2: items 3-5)
|
||||
var serverData = new List<SharedCategory>
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 },
|
||||
new() { Id = 4, Name = "Category4_Updated", SortOrder = 44 },
|
||||
|
|
@ -41,10 +41,10 @@ public class AcBinarySerializerChainReferenceTests
|
|||
var binary = serverData.ToBinary();
|
||||
|
||||
// Grid's visible list (empty initially)
|
||||
var gridVisibleList = new List<SharedCategory>();
|
||||
var gridVisibleList = new List<SharedCategory_All_True>();
|
||||
|
||||
// CRITICAL: Use Chain API to parse once, populate both cache and grid
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory>>();
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// First: Update internal cache (will become 3 items: 3-5 updated)
|
||||
chain.ThenPopulate(internalCache);
|
||||
|
|
@ -77,7 +77,7 @@ public class AcBinarySerializerChainReferenceTests
|
|||
public void JsonChainPopulate_IIdObjects_PreservesReferences()
|
||||
{
|
||||
// Setup: Create internal cache
|
||||
var internalCache = new List<SharedCategory>
|
||||
var internalCache = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1 },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2 },
|
||||
|
|
@ -85,7 +85,7 @@ public class AcBinarySerializerChainReferenceTests
|
|||
};
|
||||
|
||||
// Server returns subset
|
||||
var serverData = new List<SharedCategory>
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 2, Name = "Category2_Updated", SortOrder = 22 },
|
||||
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 }
|
||||
|
|
@ -95,10 +95,10 @@ public class AcBinarySerializerChainReferenceTests
|
|||
var json = serverData.ToJson();
|
||||
|
||||
// Grid's visible list
|
||||
var gridVisibleList = new List<SharedCategory>();
|
||||
var gridVisibleList = new List<SharedCategory_All_True>();
|
||||
|
||||
// Use JSON Chain API
|
||||
using var chain = json.JsonToChain<List<SharedCategory>>();
|
||||
using var chain = json.JsonToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// Update internal cache (will replace with 2 items)
|
||||
chain.ThenPopulate(internalCache);
|
||||
|
|
@ -163,22 +163,22 @@ public class AcBinarySerializerChainReferenceTests
|
|||
{
|
||||
// Large internal cache
|
||||
var internalCache = Enumerable.Range(1, 10)
|
||||
.Select(i => new SharedCategory { Id = i, Name = $"Category{i}", SortOrder = i * 10 })
|
||||
.Select(i => new SharedCategory_All_True { Id = i, Name = $"Category{i}", SortOrder = i * 10 })
|
||||
.ToList();
|
||||
|
||||
// Server returns items 3-7
|
||||
var serverData = Enumerable.Range(3, 5)
|
||||
.Select(i => new SharedCategory { Id = i, Name = $"Category{i}_Updated", SortOrder = i * 11 })
|
||||
.Select(i => new SharedCategory_All_True { Id = i, Name = $"Category{i}_Updated", SortOrder = i * 11 })
|
||||
.ToList();
|
||||
|
||||
var binary = serverData.ToBinary();
|
||||
|
||||
// Three different grid pages/views
|
||||
var gridPage1 = new List<SharedCategory>();
|
||||
var gridPage2 = new List<SharedCategory>();
|
||||
var gridPage3 = new List<SharedCategory>();
|
||||
var gridPage1 = new List<SharedCategory_All_True>();
|
||||
var gridPage2 = new List<SharedCategory_All_True>();
|
||||
var gridPage3 = new List<SharedCategory_All_True>();
|
||||
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory>>();
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// Update cache first
|
||||
chain.ThenPopulate(internalCache);
|
||||
|
|
@ -208,17 +208,17 @@ public class AcBinarySerializerChainReferenceTests
|
|||
[TestMethod]
|
||||
public void ChainPopulate_SimpleCase_Works()
|
||||
{
|
||||
var list1 = new List<SharedCategory>();
|
||||
var list2 = new List<SharedCategory>();
|
||||
var list1 = new List<SharedCategory_All_True>();
|
||||
var list2 = new List<SharedCategory_All_True>();
|
||||
|
||||
var serverData = new List<SharedCategory>
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Cat1", SortOrder = 10 }
|
||||
};
|
||||
|
||||
var binary = serverData.ToBinary();
|
||||
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory>>();
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// First populate
|
||||
chain.ThenPopulate(list1);
|
||||
|
|
|
|||
|
|
@ -311,15 +311,14 @@ public class AcBinarySerializerChainTests
|
|||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_ReadOnlyMemory_WorksCorrectly()
|
||||
public void DeserializeChain_ByteArray_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 42, Name = "Memory Test" };
|
||||
var binary = original.ToBinary();
|
||||
ReadOnlyMemory<byte> memory = binary;
|
||||
|
||||
// Act
|
||||
using var chain = memory.BinaryToChain<TestSimpleClass>();
|
||||
using var chain = binary.BinaryToChain<TestSimpleClass>();
|
||||
var result = chain.Value;
|
||||
|
||||
// Assert
|
||||
|
|
@ -329,16 +328,15 @@ public class AcBinarySerializerChainTests
|
|||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_ReadOnlyMemory_WorksCorrectly()
|
||||
public void PopulateChain_ByteArray_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 99, Name = "Memory Update" };
|
||||
var binary = original.ToBinary();
|
||||
ReadOnlyMemory<byte> memory = binary;
|
||||
var target = new TestSimpleClass { Id = 1, Name = "Old" };
|
||||
|
||||
// Act
|
||||
using var chain = memory.BinaryToChain(target);
|
||||
using var chain = binary.BinaryToChain(target);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(99, target.Id);
|
||||
|
|
|
|||
|
|
@ -96,9 +96,9 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
foreach (var mode in modes)
|
||||
{
|
||||
// Arrange: SAME instance used multiple times
|
||||
var userPreferences = new UserPreferences();
|
||||
var sharedTag = new SharedTag { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
|
||||
var sharedUser = new SharedUser { Id = 1, Preferences = userPreferences };
|
||||
var userPreferences = new UserPreferences_All_True();
|
||||
var sharedTag = new SharedTag_All_True { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
|
||||
var sharedUser = new SharedUser_All_True { Id = 1, Preferences = userPreferences };
|
||||
|
||||
var order = new TestOrder_Circ_Ref
|
||||
{
|
||||
|
|
@ -109,8 +109,8 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
Items =
|
||||
[
|
||||
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "Product-A", Tag = sharedTag, Assignee = sharedUser },
|
||||
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "Product-B", Tag = sharedTag, Assignee = new SharedUser { Id = 2, Preferences = userPreferences }},
|
||||
new TestOrderItem_Circ_Ref { Id = 3, ProductName = "Product-C", Tag = sharedTag, Assignee = new SharedUser { Id = 3, Preferences = userPreferences } }
|
||||
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "Product-B", Tag = sharedTag, Assignee = new SharedUser_All_True { Id = 2, Preferences = userPreferences }},
|
||||
new TestOrderItem_Circ_Ref { Id = 3, ProductName = "Product-C", Tag = sharedTag, Assignee = new SharedUser_All_True { Id = 3, Preferences = userPreferences } }
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -126,14 +126,19 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
ReferenceHandling = mode,
|
||||
UseGeneratedCode = useSgen,
|
||||
UseMetadata = useMeta,
|
||||
MaxDepth = 10
|
||||
MaxDepth = 10,
|
||||
// None mode has no ref tracking → the cycle (Items[1].ParentOrder = order) is unprotected.
|
||||
// Use Truncate so the recursion silently bottoms out with Null at the depth limit instead of throwing.
|
||||
MaxDepthBehavior = mode == ReferenceHandlingMode.None
|
||||
? MaxDepthBehavior.Truncate
|
||||
: MaxDepthBehavior.Throw
|
||||
};
|
||||
|
||||
Console.WriteLine($"\n========== ReferenceHandling: {options.ReferenceHandling}, UseSgen: {options.UseGeneratedCode}, UseMeta: {options.UseMetadata} ==========");
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
//WriteBinaryToConsole(binary);
|
||||
if (mode == ReferenceHandlingMode.None) WriteBinaryToConsole(binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>(); // Options from header
|
||||
|
||||
var objectRefCount = CountObjectRefs(binary, false);
|
||||
|
|
@ -148,12 +153,11 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
switch (mode)
|
||||
{
|
||||
case ReferenceHandlingMode.None:
|
||||
//none esetén miért nincs infinite loop??? - J.
|
||||
// Note: CountObjectRefs raw byte scan is unreliable in None mode —
|
||||
// byte 65 (ObjectRef) == ASCII 'A', so "Product-A" and circular-ref
|
||||
// depth expansion produce many false positives. Skip count assertion;
|
||||
// data integrity checks below verify correct deserialization.
|
||||
//WriteBinaryToConsole(binary);
|
||||
// Truncate semantic: cycle bottoms out with Null at MaxDepth=10 → serialize succeeds, deserialize
|
||||
// produces a partial graph where deep cyclic references read as null. Data integrity at root +
|
||||
// first few levels still holds (verified below after the switch). CountObjectRefs raw byte scan
|
||||
// is unreliable in None mode — byte 65 (ObjectRef) == ASCII 'A', so "Product-A" produces false
|
||||
// positives. Skip count assertion; rely on data integrity checks instead.
|
||||
break;
|
||||
|
||||
case ReferenceHandlingMode.OnlyId:
|
||||
|
|
@ -167,7 +171,7 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
break;
|
||||
|
||||
case ReferenceHandlingMode.All:
|
||||
// IId types + Non-IId (UserPreferences) should have ObjectRefs
|
||||
// IId types + Non-IId (UserPreferences_All_True) should have ObjectRefs
|
||||
Assert.IsTrue(objectRefCount >= 4, $"[{mode}] Expected at least 4 ObjectRefs, found {objectRefCount}");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag, $"[{mode}] Tag reference identity failed");
|
||||
Assert.AreSame(result.Owner, result.Items[0].Assignee, $"[{mode}] User reference identity failed");
|
||||
|
|
@ -176,7 +180,7 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
Assert.AreSame(result.Parent, result.Items[1]);
|
||||
|
||||
// Non-IId should also have reference identity in All mode
|
||||
Assert.AreSame(result.Owner.Preferences, result.Items[0].Assignee.Preferences, $"[{mode}] UserPreferences reference identity failed - Non-IId should work in All mode!");
|
||||
Assert.AreSame(result.Owner.Preferences, result.Items[0].Assignee.Preferences, $"[{mode}] UserPreferences_All_True reference identity failed - Non-IId should work in All mode!");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -207,44 +211,44 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
{
|
||||
// Arrange: DIFFERENT instances but SAME IId.Id
|
||||
// CRITICAL: Multiple DIFFERENT TYPES all have Id=1 - must not be confused!
|
||||
var sharedTag = new SharedTag { Id = 55, Name = "ImportantTag_55", Color = "#FF0000" };
|
||||
var order = new TestOrder
|
||||
var sharedTag = new SharedTag_All_True { Id = 55, Name = "ImportantTag_55", Color = "#FF0000" };
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
// All three types have Id=1 - tests (Type, Id) keying, not just Id
|
||||
PrimaryTag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Owner = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
|
||||
Category = new SharedCategory { Id = 1, Name = "Category_Id1", SortOrder = 10 },
|
||||
PrimaryTag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Owner = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
|
||||
Category = new SharedCategory_All_True { Id = 1, Name = "Category_Id1", SortOrder = 10 },
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 1,
|
||||
ProductName = "Product-A",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
},
|
||||
new TestOrderItem
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 2,
|
||||
ProductName = "Product-B",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
},
|
||||
new TestOrderItem
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 3,
|
||||
ProductName = "Product-C",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = order.ToBinary();
|
||||
var result = binary.BinaryTo<TestOrder>();
|
||||
var result = binary.BinaryTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: Check if ObjectRef is used (IId-based deduplication active)
|
||||
var objectRefCount = CountObjectRefs(binary);
|
||||
|
|
@ -254,11 +258,11 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
// Assert 3: Reference identity - same TYPE with same Id should be same reference
|
||||
// Tags with Id=1 should all be same reference
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag,
|
||||
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
|
||||
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[1].Tag,
|
||||
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
|
||||
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[2].Tag,
|
||||
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
|
||||
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
|
||||
// Users with Id=1 should all be same reference
|
||||
Assert.AreSame(result.Owner, result.Items[0].Assignee,
|
||||
|
|
@ -325,38 +329,38 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
public void DifferentInstances_SameIId_SmallerBinaryWithDataIntegrity()
|
||||
{
|
||||
// Arrange: 10 different instances with SAME IId
|
||||
var orderWithSameIId = new TestOrder
|
||||
var orderWithSameIId = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "SAME-IID",
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
|
||||
{
|
||||
Id = i,
|
||||
ProductName = $"Product-{i}",
|
||||
// All have SAME IId.Id = 1, but DIFFERENT instances
|
||||
Assignee = new SharedUser { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Arrange: 10 different instances with DIFFERENT IIds
|
||||
var orderWithDifferentIIds = new TestOrder
|
||||
var orderWithDifferentIIds = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "DIFF-IID",
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
|
||||
{
|
||||
Id = i,
|
||||
ProductName = $"Product-{i}",
|
||||
// All have DIFFERENT IId.Id
|
||||
Assignee = new SharedUser { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
|
||||
Assignee = new SharedUser_All_True { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Act
|
||||
var sameIIdBinary = orderWithSameIId.ToBinary();
|
||||
var diffIIdBinary = orderWithDifferentIIds.ToBinary();
|
||||
var sameIIdResult = sameIIdBinary.BinaryTo<TestOrder>();
|
||||
var diffIIdResult = diffIIdBinary.BinaryTo<TestOrder>();
|
||||
var sameIIdResult = sameIIdBinary.BinaryTo<TestOrder_All_True>();
|
||||
var diffIIdResult = diffIIdBinary.BinaryTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: Size comparison
|
||||
Console.WriteLine($"Same IId binary size: {sameIIdBinary.Length} bytes");
|
||||
|
|
@ -506,15 +510,15 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
public void IIdDetection_Diagnostic()
|
||||
{
|
||||
// Test GetIdInfo directly
|
||||
var sharedTagType = typeof(SharedTag);
|
||||
var sharedTagType = typeof(SharedTag_All_True);
|
||||
var idInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedTagType);
|
||||
|
||||
Console.WriteLine($"SharedTag GetIdInfo: IsId={idInfo.IsId}, IdType={idInfo.IdType?.Name}");
|
||||
Assert.IsTrue(idInfo.IsId, "SharedTag should be detected as IId<int>");
|
||||
Assert.AreEqual(typeof(int), idInfo.IdType, "SharedTag Id type should be int");
|
||||
Console.WriteLine($"SharedTag_All_True GetIdInfo: IsId={idInfo.IsId}, IdType={idInfo.IdType?.Name}");
|
||||
Assert.IsTrue(idInfo.IsId, "SharedTag_All_True should be detected as IId<int>");
|
||||
Assert.AreEqual(typeof(int), idInfo.IdType, "SharedTag_All_True Id type should be int");
|
||||
|
||||
// Test SharedUser
|
||||
var sharedUserType = typeof(SharedUser);
|
||||
var sharedUserType = typeof(SharedUser_All_True);
|
||||
var userIdInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedUserType);
|
||||
Console.WriteLine($"SharedUser GetIdInfo: IsId={userIdInfo.IsId}, IdType={userIdInfo.IdType?.Name}");
|
||||
Assert.IsTrue(userIdInfo.IsId, "SharedUser should be detected as IId<int>");
|
||||
|
|
@ -532,7 +536,7 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
[TestMethod]
|
||||
public void SharedCategory_DataIntegrity()
|
||||
{
|
||||
var categories = new List<SharedCategory>
|
||||
var categories = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1, IsDefault = true },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2, ParentCategoryId = 1 },
|
||||
|
|
@ -540,7 +544,7 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
};
|
||||
|
||||
var binary = categories.ToBinary();
|
||||
var result = binary.BinaryTo<List<SharedCategory>>();
|
||||
var result = binary.BinaryTo<List<SharedCategory_All_True>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
|
@ -557,4 +561,93 @@ public class AcBinarySerializerIIdReferenceTests
|
|||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SGen-emit writer/reader ref-handling asymmetry — regression target
|
||||
|
||||
/// <summary>
|
||||
/// Target test for the SGen-emit writer/reader asymmetry hypothesis — covers BOTH
|
||||
/// collection-element AND dictionary-value ref-marker paths in a single graph.
|
||||
/// <para>
|
||||
/// Setup:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TestRefAsymParent</c> [AcBinarySerializable(false)] — parent EnableRefHandlingFeature=false.</item>
|
||||
/// <item><c>TestRefAsymChild</c> [AcBinarySerializable(true)] — child IId<int>, all features ON.</item>
|
||||
/// <item>Same child instance referenced twice in the parent's <c>Children</c> list
|
||||
/// AND twice as VALUES in the parent's <c>ChildrenMap</c> dictionary.</item>
|
||||
/// <item>Runtime <c>ReferenceHandling=All</c> + <c>Interning=All</c> (via Default options).</item>
|
||||
/// <item><c>MarkerDecimal</c> property AFTER the list — drift detection slot (decimal = 16 fixed bytes).</item>
|
||||
/// <item><c>MarkerDecimal2</c> property AFTER the dictionary — second drift detection slot,
|
||||
/// catches the symmetric dict-value emit asymmetry (EmitReadDictionary:482).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expected if the asymmetry-hypothesis holds: the writer (runtime via
|
||||
/// WriteObjectGenerated bridge) emits ObjectRefFirst+ObjectRef for the duplicates; the SGen
|
||||
/// reader-emit's zero-branch path (parent flag false guarding out the ref-aware switch)
|
||||
/// misreads the VarUInt cacheIdx as a property-marker byte → DECIMAL_DRIFT exception or
|
||||
/// value-mismatch on MarkerDecimal / MarkerDecimal2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expected if the hypothesis is WRONG: the test passes — different fix direction needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction()
|
||||
{
|
||||
var sharedChild = new TestRefAsymChild { Id = 1, Name = "Shared" };
|
||||
var parent = new TestRefAsymParent
|
||||
{
|
||||
Id = 100,
|
||||
Children = new List<TestRefAsymChild> { sharedChild, sharedChild },
|
||||
MarkerDecimal = 999.99m,
|
||||
ChildrenMap = new Dictionary<int, TestRefAsymChild>
|
||||
{
|
||||
{ 10, sharedChild },
|
||||
{ 20, sharedChild },
|
||||
},
|
||||
MarkerDecimal2 = 888.88m,
|
||||
};
|
||||
|
||||
var options = AcBinarySerializerOptions.Default; // RefHandling=All, Interning=All
|
||||
options.UseGeneratedCode = true;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(parent, options);
|
||||
|
||||
// Sanity check: did the writer actually emit an ObjectRef marker for the duplicates?
|
||||
var objectRefCount = CountObjectRefs(bytes, writeBinaryToConsole: false);
|
||||
Console.WriteLine($"Wire size: {bytes.Length}, ObjectRef occurrences: {objectRefCount}");
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestRefAsymParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(result, "Deserialize returned null — wire corruption");
|
||||
Assert.AreEqual(parent.Id, result.Id, "Parent.Id mismatch — possible drift before the list");
|
||||
|
||||
// --- Collection-element path (EmitReadCollectionElement) ---
|
||||
Assert.IsNotNull(result.Children, "Children list was null after round-trip");
|
||||
Assert.AreEqual(2, result.Children.Count, "Children count mismatch");
|
||||
Assert.IsNotNull(result.Children[0]);
|
||||
Assert.IsNotNull(result.Children[1]);
|
||||
Assert.AreEqual(sharedChild.Id, result.Children[0].Id, "Children[0].Id mismatch");
|
||||
Assert.AreEqual(sharedChild.Name, result.Children[0].Name, "Children[0].Name mismatch");
|
||||
Assert.AreEqual(sharedChild.Id, result.Children[1].Id, "Children[1].Id mismatch — drift on the duplicate");
|
||||
Assert.AreEqual(sharedChild.Name, result.Children[1].Name, "Children[1].Name mismatch — drift on the duplicate");
|
||||
Assert.AreEqual(parent.MarkerDecimal, result.MarkerDecimal,
|
||||
"MarkerDecimal drift — wire-position desync after the Children list (smoking gun for collection-element SGen-emit asymmetry)");
|
||||
|
||||
// --- Dictionary-value path (EmitReadDictionary) ---
|
||||
Assert.IsNotNull(result.ChildrenMap, "ChildrenMap was null after round-trip");
|
||||
Assert.AreEqual(2, result.ChildrenMap.Count, "ChildrenMap count mismatch");
|
||||
Assert.IsTrue(result.ChildrenMap.ContainsKey(10), "ChildrenMap missing key=10");
|
||||
Assert.IsTrue(result.ChildrenMap.ContainsKey(20), "ChildrenMap missing key=20");
|
||||
Assert.IsNotNull(result.ChildrenMap[10]);
|
||||
Assert.IsNotNull(result.ChildrenMap[20]);
|
||||
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[10].Id, "ChildrenMap[10].Id mismatch");
|
||||
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[10].Name, "ChildrenMap[10].Name mismatch");
|
||||
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[20].Id, "ChildrenMap[20].Id mismatch — drift on the dict-value duplicate");
|
||||
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[20].Name, "ChildrenMap[20].Name mismatch — drift on the dict-value duplicate");
|
||||
Assert.AreEqual(parent.MarkerDecimal2, result.MarkerDecimal2,
|
||||
"MarkerDecimal2 drift — wire-position desync after the ChildrenMap dictionary (smoking gun for dict-value SGen-emit asymmetry)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
using System;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Focused repro tests for <c>MaxDepthBehavior.Truncate</c> on cyclic graphs.
|
||||
///
|
||||
/// Tracks <c>BINARY_ISSUES.md#accore-bin-i-t7k3</c>: SGen path produces wire-misalignment
|
||||
/// (<c>DECIMAL_DRIFT</c> on round-trip) when Truncate fires inside a cycle. Runtime path
|
||||
/// works correctly with the same data — that's the control test for diagnosis.
|
||||
///
|
||||
/// Designed for interactive debugger sessions: minimal graph, small <c>MaxDepth=5</c>,
|
||||
/// single cycle property. Step through `WriteObjectFullMarkerIId` to compare runtime
|
||||
/// vs SGen call sequences at the truncation boundary.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerMaxDepthTruncateTests
|
||||
{
|
||||
private const int MaxDepthForTest = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the minimal cyclic graph used by most tests below.
|
||||
/// Cycle: <c>order → Items[0] → ParentOrder → order → …</c>.
|
||||
/// Only primitive properties on the leaf entities so the body is short and the wire is easy to diff.
|
||||
/// </summary>
|
||||
private static TestOrder_Circ_Ref BuildMinimalCycle()
|
||||
{
|
||||
var order = new TestOrder_Circ_Ref
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "TEST-001",
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem_Circ_Ref
|
||||
{
|
||||
Id = 10,
|
||||
ProductName = "Product-A",
|
||||
Quantity = 5
|
||||
}
|
||||
]
|
||||
};
|
||||
order.Items[0].ParentOrder = order; // ← closes the cycle
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the cyclic graph PLUS sets the polymorphic <c>Parent</c> property (declared <c>object?</c>)
|
||||
/// to a non-IId concrete instance — mirroring the failing SameInstance test's setup for None mode.
|
||||
/// This routes through <see cref="WriteValueNonPrimitiveWithWrapperPoly"/> →
|
||||
/// <see cref="WriteObjectPolymorphic"/> on every cycle level (Parent is written at every TestOrder body).
|
||||
/// </summary>
|
||||
private static TestOrder_Circ_Ref BuildCycleWithPolymorphicParent()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
// Parent is `object?` on TestOrder_Circ_Ref — polymorphic write path.
|
||||
// UserPreferences_All_True is non-IId, leaf-like (Language, LightTheme strings + scalars), no further refs.
|
||||
order.Parent = new UserPreferences_All_True { Language = "en-US", Theme = "light" };
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic helper: dump the wire bytes as hex for visual comparison.
|
||||
/// Useful in the debugger to spot the runtime-vs-SGen wire diff.
|
||||
/// </summary>
|
||||
private static void DumpWire(string label, byte[] wire)
|
||||
{
|
||||
Console.WriteLine($"=== {label} | {wire.Length} bytes ===");
|
||||
Console.WriteLine(BitConverter.ToString(wire));
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONTROL TEST — runtime path with Truncate. Should pass.
|
||||
/// If this fails, the bug is broader than the SGen path; fix here first.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Runtime_None_Truncate_CyclicGraph_RoundTrips()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = false, // ← runtime path
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("Runtime + Truncate", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
// Assert: serialize+deserialize succeeds; root + first-level data intact;
|
||||
// ParentOrder is null at the truncation boundary (instead of a full cycle round-trip).
|
||||
Assert.IsNotNull(result, "Deserialize result should not be null");
|
||||
Assert.AreEqual(1, result.Id, "root Id");
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
|
||||
Assert.IsNotNull(result.Items, "Items list should be materialized");
|
||||
Assert.AreEqual(1, result.Items.Count, "Items count");
|
||||
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
|
||||
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
|
||||
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
|
||||
// ParentOrder may or may not be set depending on where truncation fires —
|
||||
// the contract is "the deserialize must not throw and root-level data must be intact".
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BUG REPRO — SGen path with Truncate. Currently fails with <c>DECIMAL_DRIFT</c>
|
||||
/// on round-trip. Same input as the runtime control above; only <c>UseGeneratedCode</c> differs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Step through with the VS debugger:
|
||||
/// 1. Break in <c>WriteObjectFullMarkerIId</c> for both runs (runtime test above + this).
|
||||
/// 2. Compare <c>_position</c>, <c>_recursionDepth</c>, and the wire-bytes-just-written at each
|
||||
/// call-site between the two paths.
|
||||
/// 3. Identify the byte position where the SGen wire diverges from the runtime wire.
|
||||
/// 4. Likely culprits to inspect:
|
||||
/// - <c>TryEnterRecursion</c> inc/dec balance on the SGen-emit code path
|
||||
/// - <c>WriteObjectFullMarkerIId</c> ref-handling branches (2nd-occurrence ExitRecursion undo)
|
||||
/// - SGen-emitted property-loop ordering vs runtime <c>WritePropertiesMarkerless</c>
|
||||
/// </remarks>
|
||||
[TestMethod]
|
||||
public void Sgen_None_Truncate_CyclicGraph_RoundTrips()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = true, // ← SGen path (triggers the bug)
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("SGen + Truncate", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
// Assert: same as runtime control. Currently throws DECIMAL_DRIFT.
|
||||
Assert.IsNotNull(result, "Deserialize result should not be null");
|
||||
Assert.AreEqual(1, result.Id, "root Id");
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
|
||||
Assert.IsNotNull(result.Items, "Items list should be materialized");
|
||||
Assert.AreEqual(1, result.Items.Count, "Items count");
|
||||
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
|
||||
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
|
||||
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SGen + Truncate + useMetadata=true variant. Also currently fails (multi-byte marker variant
|
||||
/// of the same underlying issue). Useful for the debug session to confirm whether the fix
|
||||
/// also covers the metadata code path or just the simple Object-marker path.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Sgen_None_Truncate_UseMetadata_CyclicGraph_RoundTrips()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = true,
|
||||
UseMetadata = true, // ← multi-byte marker (ObjectWithMetadata + inline meta)
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("SGen + Truncate + useMetadata", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
// Assert: same root-level integrity expectation.
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
Assert.AreEqual("Product-A", result.Items[0].ProductName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONTROL — runtime + polymorphic Parent. Should pass.
|
||||
/// Compared to <see cref="Sgen_None_Truncate_PolymorphicCycle_RoundTrips"/> below: same data, different code path.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Runtime_None_Truncate_PolymorphicCycle_RoundTrips()
|
||||
{
|
||||
var order = BuildCycleWithPolymorphicParent();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = false,
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("Runtime + Truncate + PolymorphicParent", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
// Root-level Parent should round-trip (depth 1 — well below MaxDepth).
|
||||
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
|
||||
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REPRO — SGen + polymorphic Parent inside a cycle. This is the failing case in the
|
||||
/// original SameInstance test for (useSgen=true, useMeta=false) None mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cycle (Items[0].ParentOrder = order) makes <c>TestOrder.WriteProperties</c> recurse.
|
||||
/// At each cycle level, the body writes its <c>Parent</c> property polymorphically via
|
||||
/// <c>WriteValueNonPrimitiveWithWrapperPoly</c> → <c>WriteObjectPolymorphic</c>. When the
|
||||
/// cycle reaches <c>MaxDepth</c>, the SGen path produces wire bytes that the SGen reader
|
||||
/// later mis-interprets (<c>DECIMAL_DRIFT</c> on TotalAmount at the deepest unwind frame).
|
||||
/// Runtime control above with the same data works correctly — diff the two wires to find
|
||||
/// where SGen diverges.
|
||||
///
|
||||
/// Focus debug-watch targets at the truncation boundary:
|
||||
/// - <see cref="AcBinarySerializer.WriteObjectPolymorphic"/> Truncate path (Null written)
|
||||
/// - <see cref="AcBinarySerializer.BinarySerializationContext{TOutput}.TryEnterRecursion"/> inc/dec balance
|
||||
/// - The polymorphic-prefix wire bytes (FixObj-slot vs ObjectWithTypeName) immediately before/after the truncate
|
||||
/// </remarks>
|
||||
[TestMethod]
|
||||
public void Sgen_None_Truncate_PolymorphicCycle_RoundTrips()
|
||||
{
|
||||
var order = BuildCycleWithPolymorphicParent();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = true, // ← SGen path (triggers the bug)
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("SGen + Truncate + PolymorphicParent", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
|
||||
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-cyclic shallow case — the primary delta-update use case.
|
||||
/// Serialize an entity with intentionally truncated nested collections (MaxDepth=1),
|
||||
/// verify root + first-level scalar properties round-trip while nested complex ones become null.
|
||||
/// Both runtime and SGen paths should pass this. If SGen fails here too, the bug isn't
|
||||
/// cycle-specific — it's pure Truncate-emission corruption.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow(false, DisplayName = "Runtime")]
|
||||
[DataRow(true, DisplayName = "SGen")]
|
||||
public void Sgen_Or_Runtime_None_Truncate_NoCycle_ShallowRoundTrip(bool useSgen)
|
||||
{
|
||||
var order = new TestOrder_Circ_Ref
|
||||
{
|
||||
Id = 42,
|
||||
OrderNumber = "DELTA-UPDATE-001",
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "P1" },
|
||||
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "P2" }
|
||||
]
|
||||
// No cycle. Items array elements truncate at MaxDepth=1.
|
||||
};
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = useSgen,
|
||||
UseMetadata = false,
|
||||
MaxDepth = 1, // root + 1 level
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire($"NoCycle Truncate ({(useSgen ? "SGen" : "Runtime")})", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(42, result.Id);
|
||||
Assert.AreEqual("DELTA-UPDATE-001", result.OrderNumber);
|
||||
// Items elements are at depth 2 — get truncated to null at MaxDepth=1.
|
||||
// Result.Items list itself should exist (it's at depth 1), but elements should be null.
|
||||
// The exact element-or-null result is depth-implementation-dependent — the strict invariant
|
||||
// is "deserialize doesn't throw and root scalars are intact".
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
using System.IO.Pipelines;
|
||||
using System.IO.Pipes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-platform NamedPipe IPC roundtrip tests proving AcBinarySerializer's streaming framework
|
||||
/// works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources without per-transport adapters.
|
||||
///
|
||||
/// <para>The serializer/deserializer surface intentionally has NO NamedPipe-specific helpers —
|
||||
/// the tests own the <see cref="NamedPipeServerStream"/> / <see cref="NamedPipeClientStream"/>
|
||||
/// lifecycle directly and call the generic
|
||||
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> +
|
||||
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
|
||||
/// primitives, with the receive-side drain implemented via the test-only
|
||||
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. The same generic
|
||||
/// primitives apply to FileStream / NetworkStream / custom transports — consumers own the
|
||||
/// transport lifecycle, framework stays transport-agnostic.</para>
|
||||
///
|
||||
/// <para>With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk
|
||||
/// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior.</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerNamedPipeTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task RoundTrip_SmallChunkSize_PayloadEquals()
|
||||
{
|
||||
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
|
||||
|
||||
// 256-byte chunk size = Kestrel slab default; small enough to force multi-chunk framing
|
||||
// for our 50-item payload, exercises the AsyncSegment chunked wire format end-to-end.
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||||
var original = CreatePayload(50);
|
||||
|
||||
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
AssertPayloadEquals(original, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RoundTrip_LargeScalePayload_ChunkSize256_StructuralEquality()
|
||||
{
|
||||
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
|
||||
// = ~3700 deeply-nested objects with shared references (50 tags, 20 users, metadata, 10 categories).
|
||||
// Serialized size ~few hundred KB → many chunks at chunkSize=256 → real backpressure-driven streaming
|
||||
// (sequential per-chunk flush on StreamPipeWriter, bytes flow incrementally as consumer drains).
|
||||
|
||||
#if DEBUG
|
||||
// Capture BOTH receiver and sender state to diagnose StreamPipeWriter interaction if needed.
|
||||
var diagLogs = new List<string>();
|
||||
|
||||
AsyncPipeReaderInput.DiagnosticLog = msg => diagLogs.Add($"[R] {msg}");
|
||||
AsyncPipeWriterOutput.DiagnosticLog = msg => diagLogs.Add($"[S] {msg}");
|
||||
#endif
|
||||
try
|
||||
{
|
||||
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||||
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
|
||||
|
||||
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(original.Status, result.Status);
|
||||
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
|
||||
|
||||
// Deep structure: count items + pallets + measurements + points must match exactly
|
||||
var origCounts = CountTestOrderHierarchy(original);
|
||||
var resultCounts = CountTestOrderHierarchy(result);
|
||||
|
||||
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
|
||||
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
|
||||
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
|
||||
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
|
||||
}
|
||||
finally
|
||||
{
|
||||
#if DEBUG
|
||||
AsyncPipeReaderInput.DiagnosticLog = null;
|
||||
AsyncPipeWriterOutput.DiagnosticLog = null;
|
||||
|
||||
if (diagLogs.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"=== Sender [S] + Receiver [R] DiagnosticLog trail ({diagLogs.Count} entries) ===");
|
||||
|
||||
// Print last 60 entries (most relevant to failure point)
|
||||
var startIdx = Math.Max(0, diagLogs.Count - 60);
|
||||
if (startIdx > 0) Console.WriteLine($" ... ({startIdx} earlier entries elided)");
|
||||
|
||||
for (var i = startIdx; i < diagLogs.Count; i++) Console.WriteLine($" [{i}] {diagLogs[i]}");
|
||||
|
||||
Console.WriteLine($"=== End DiagnosticLog ===");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the full NamedPipe lifecycle: binds server, accepts connect, drives the generic
|
||||
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> on
|
||||
/// the client side, and on the server side runs the canonical drain+deserialize pair
|
||||
/// (test-only <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> on the calling thread,
|
||||
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
|
||||
/// on a Task.Run BG thread). The framework helpers know nothing about NamedPipe — only PipeWriter /
|
||||
/// PipeReader.
|
||||
/// </summary>
|
||||
private static async Task<T?> RunNamedPipeRoundTripAsync<T>(string pipeName, T original, AcBinarySerializerOptions opts)
|
||||
{
|
||||
// Server-side bind is synchronous (NamedPipeServerStream ctor registers the pipe with
|
||||
// the OS), so the client can immediately attempt connect once we hand off to async.
|
||||
await using var pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Message, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
var receiveTask = Task.Run(async () =>
|
||||
{
|
||||
await pipeServer.WaitForConnectionAsync().ConfigureAwait(false);
|
||||
var pipeReader = PipeReader.Create(pipeServer);
|
||||
|
||||
// Inlined version of what the removed DeserializeFromPipeReaderAsync used to do:
|
||||
// single-message mode + drain on calling thread + deserialize on Task.Run BG.
|
||||
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2, multiMessage: false);
|
||||
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<T>(input, opts));
|
||||
await input.DrainFromAsync(pipeReader).ConfigureAwait(false);
|
||||
return await deserTask.ConfigureAwait(false);
|
||||
});
|
||||
|
||||
await using var pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
await pipeClient.ConnectAsync().ConfigureAwait(false);
|
||||
|
||||
var pipeWriter = PipeWriter.Create(pipeClient);
|
||||
try
|
||||
{
|
||||
// Public PipeWriter overload (raw chunked stream — no per-chunk frame headers,
|
||||
// bit-compatible with Serialize(v, opts) byte[] output). Auto-selects sequential
|
||||
// flush strategy because PipeWriter.Create(stream) returns StreamPipeWriter
|
||||
// (race-incompatible with parallel send).
|
||||
AcBinarySerializer.SerializeChunked(original, pipeWriter, opts);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipeWriter.CompleteAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await receiveTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
|
||||
{
|
||||
var items = order.Items.Count;
|
||||
int pallets = 0, measurements = 0, points = 0;
|
||||
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
pallets += item.Pallets.Count;
|
||||
foreach (var p in item.Pallets)
|
||||
{
|
||||
measurements += p.Measurements.Count;
|
||||
points += p.Measurements.Sum(m => m.Points.Count);
|
||||
}
|
||||
}
|
||||
|
||||
return (items, pallets, measurements, points);
|
||||
}
|
||||
|
||||
// Note: a "default chunk size" test was deliberately omitted. The default
|
||||
// AcBinarySerializerOptions.BufferWriterChunkSize used to be 65536, which exceeded the
|
||||
// UINT16 max (256). Fixed in this work to 256. Tests above explicitly set chunk size
|
||||
// for reproducibility regardless of default.
|
||||
|
||||
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
|
||||
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
items.Add(new TestEntityWithDateTimeAndInt
|
||||
{
|
||||
Id = i + 1,
|
||||
IntValue = i * 3,
|
||||
Created = now.AddMinutes(-i),
|
||||
Modified = now.AddMinutes(i),
|
||||
StatusCode = i % 4,
|
||||
Name = $"item-{i}"
|
||||
});
|
||||
}
|
||||
|
||||
return new TestParentWithDateTimeItemCollection
|
||||
{
|
||||
Id = 11,
|
||||
Name = "named-pipe-roundtrip",
|
||||
Created = now,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
|
||||
{
|
||||
Assert.AreEqual(expected.Id, actual.Id);
|
||||
Assert.AreEqual(expected.Name, actual.Name);
|
||||
Assert.AreEqual(expected.Created, actual.Created);
|
||||
|
||||
Assert.IsNotNull(expected.Items);
|
||||
Assert.IsNotNull(actual.Items);
|
||||
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
|
||||
|
||||
for (var i = 0; i < expected.Items.Count; i++)
|
||||
{
|
||||
var e = expected.Items[i];
|
||||
var a = actual.Items[i];
|
||||
|
||||
Assert.AreEqual(e.Id, a.Id);
|
||||
Assert.AreEqual(e.IntValue, a.IntValue);
|
||||
Assert.AreEqual(e.Created, a.Created);
|
||||
Assert.AreEqual(e.Modified, a.Modified);
|
||||
Assert.AreEqual(e.StatusCode, a.StatusCode);
|
||||
Assert.AreEqual(e.Name, a.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,729 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO;
|
||||
using System.IO.Pipelines;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AsyncPipeReaderInput"/> (Step 1, ACCORE-BIN-T-D6H4) and the
|
||||
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension (Step 2, ACCORE-BIN-T-M2K1),
|
||||
/// plus the real parallel pipeline test (Step 3, ACCORE-BIN-T-V7C9), plus runtime type-detect
|
||||
/// sanity pinning (Step 4).
|
||||
///
|
||||
/// <para>Tests run with <see cref="AsyncPipeReaderInput"/>'s default <c>multiMessage = true</c> —
|
||||
/// <see cref="AsyncPipeReaderInput.Feed"/> expects the AsyncSegment chunked wire format
|
||||
/// <c>[201][UINT16 LE size][data]</c> per chunk, tolerates <c>[200]</c> CHUNK_START prefix, and
|
||||
/// signals end-of-stream on <c>[202]</c> CHUNK_END. The <see cref="WrapInChunkFrame"/> helper
|
||||
/// wraps test data into single chunk frames; multi-chunk tests concatenate multiple frames.</para>
|
||||
///
|
||||
/// <para>Wire format identical to <see cref="AsyncPipeWriterOutput"/> framed output and to
|
||||
/// SignalR's <c>AcBinaryHubProtocol.TryParseChunkData</c> input — unified across all transports
|
||||
/// per ADR-0003 §9.</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerPipeParallelTests
|
||||
{
|
||||
// ====================================================================
|
||||
// Step 1 — AsyncPipeReaderInput contract (ACCORE-BIN-T-D6H4)
|
||||
// ====================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_EmptyData_NoOp()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(ReadOnlySpan<byte>.Empty);
|
||||
input.Complete();
|
||||
|
||||
// No data → TryAdvanceSegment returns false immediately
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
Assert.AreEqual(0, bufferLength);
|
||||
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_AppendsBytes_AccessibleViaTryAdvanceSegment()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
|
||||
input.Feed(WrapInChunkFrame(data)); // [201][UINT16=8][1..8]
|
||||
input.Complete();
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Initialize_BeforeFeed_ReturnsEmptyBuffer()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
|
||||
Assert.IsNotNull(buffer);
|
||||
Assert.AreEqual(0, position);
|
||||
Assert.AreEqual(0, bufferLength);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Initialize_AfterFeed_ReturnsAvailableData()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
var data = new byte[] { 10, 20, 30 };
|
||||
input.Feed(WrapInChunkFrame(data));
|
||||
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
|
||||
Assert.AreEqual(0, position);
|
||||
Assert.AreEqual(3, bufferLength);
|
||||
Assert.AreEqual((byte)10, buffer[0]);
|
||||
Assert.AreEqual((byte)20, buffer[1]);
|
||||
Assert.AreEqual((byte)30, buffer[2]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Complete_AllConsumed_TryAdvanceSegmentReturnsFalse()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Complete();
|
||||
|
||||
// Simulate consumer that has read all 3 bytes
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
position = bufferLength;
|
||||
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Complete_WithLeftoverData_TryAdvanceSegmentReturnsTrueWithRemainder()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Feed(WrapInChunkFrame([4, 5, 6]));
|
||||
input.Complete();
|
||||
|
||||
// Simulate consumer that has read 3 of 6 bytes — advance should expose the rest
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
Assert.AreEqual(6, bufferLength);
|
||||
|
||||
position = 3;
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
|
||||
Assert.IsTrue(hasMore);
|
||||
Assert.AreEqual(3, position);
|
||||
Assert.AreEqual(6, bufferLength);
|
||||
Assert.AreEqual((byte)4, buffer[3]);
|
||||
Assert.AreEqual((byte)5, buffer[4]);
|
||||
Assert.AreEqual((byte)6, buffer[5]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Grow_PastInitialCapacity_BytesPreservedAcrossGrows()
|
||||
{
|
||||
// Initial capacity = 16, feed > 16 bytes consecutively (no consume between) → forces grow
|
||||
using var input = new AsyncPipeReaderInput(16);
|
||||
|
||||
var data = new byte[64];
|
||||
for (var i = 0; i < data.Length; i++) data[i] = (byte)i;
|
||||
|
||||
// Feed in chunks that overflow the initial buffer (each wrapped in a chunk frame)
|
||||
input.Feed(WrapInChunkFrame(data, 0, 16));
|
||||
input.Feed(WrapInChunkFrame(data, 16, 16)); // grow #1
|
||||
input.Feed(WrapInChunkFrame(data, 32, 32)); // grow #2
|
||||
input.Complete();
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ProducerConsumer_Concurrency_AllBytesDeliveredInOrder()
|
||||
{
|
||||
const int totalBytes = 8192;
|
||||
const int chunkSize = 17; // intentional: not a power of 2, exercises partial fills
|
||||
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var expected = new byte[totalBytes];
|
||||
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
|
||||
|
||||
var consumeTask = Task.Run(() => ConsumeAll(input));
|
||||
|
||||
var produceTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < expected.Length)
|
||||
{
|
||||
var take = Math.Min(chunkSize, expected.Length - offset);
|
||||
|
||||
input.Feed(WrapInChunkFrame(expected, offset, take));
|
||||
offset += take;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.Complete();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(consumeTask, produceTask);
|
||||
|
||||
var actual = consumeTask.Result;
|
||||
CollectionAssert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ProducerConsumer_SlidingWindowCycle_ManyResetsHandledCorrectly()
|
||||
{
|
||||
// Small initial buffer + slow producer drives many reset-to-0 cycles.
|
||||
const int totalBytes = 32 * 1024;
|
||||
const int chunkSize = 7;
|
||||
|
||||
using var input = new AsyncPipeReaderInput(32);
|
||||
var expected = new byte[totalBytes];
|
||||
|
||||
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
|
||||
|
||||
var consumeTask = Task.Run(() => ConsumeAll(input));
|
||||
|
||||
var produceTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < expected.Length)
|
||||
{
|
||||
var take = Math.Min(chunkSize, expected.Length - offset);
|
||||
input.Feed(WrapInChunkFrame(expected, offset, take));
|
||||
offset += take;
|
||||
if ((offset & 0x7F) == 0) await Task.Yield();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.Complete();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(consumeTask, produceTask);
|
||||
|
||||
var actual = consumeTask.Result;
|
||||
|
||||
Assert.AreEqual(expected.Length, actual.Length);
|
||||
CollectionAssert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
var input = new AsyncPipeReaderInput(64);
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Complete();
|
||||
|
||||
input.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_InvalidCapacity_ThrowsArgumentOutOfRange()
|
||||
{
|
||||
_ = Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new AsyncPipeReaderInput(0));
|
||||
_ = Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new AsyncPipeReaderInput(-1));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_PartialFrameAcrossCalls_ParsedCorrectly()
|
||||
{
|
||||
// Verifies the framing state machine survives partial frame headers / sizes / data
|
||||
// split across multiple Feed calls.
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
var data = new byte[] { 10, 20, 30, 40, 50 };
|
||||
var frame = WrapInChunkFrame(data); // 8 bytes total: [201][05][00][10][20][30][40][50]
|
||||
|
||||
// Feed byte-by-byte to stress the state machine
|
||||
for (var i = 0; i < frame.Length; i++) input.Feed(frame.AsSpan(i, 1));
|
||||
input.Complete();
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_ChunkEndMarker_AutoResetsForNextMessage()
|
||||
{
|
||||
// [202] CHUNK_END is end-of-MESSAGE on the WIRE — NOT end-of-session and NOT, by itself,
|
||||
// a buffer-cursor recycle. On [202], the framing-state machine resets to AwaitingHeader so
|
||||
// the next [201] header is parsed correctly; buffer-cursor recycling is armed separately by
|
||||
// the consumer via MessageDone() (typically from the AcBinaryDeserializer.Deserialize<T>(
|
||||
// AsyncPipeReaderInput, opts) finally block, AFTER the deserialiser has finished reading
|
||||
// the structurally-complete graph). See BINARY_ISSUES.md#accore-bin-i-q4t8 / R5K2 fix.
|
||||
// Session end is signalled separately by an external Complete() / stream-EOF.
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
// Message 1
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Feed([202]); // CHUNK_END — framing reset only (no buffer-cursor recycle, no completion)
|
||||
|
||||
// First message is consumable
|
||||
input.Initialize(out var buf1, out var pos1, out var bufLen1);
|
||||
Assert.AreEqual(3, bufLen1);
|
||||
Assert.AreEqual(1, buf1[0]);
|
||||
Assert.AreEqual(2, buf1[1]);
|
||||
Assert.AreEqual(3, buf1[2]);
|
||||
|
||||
// Simulate the AcBinaryDeserializer.Deserialize<T>(input, opts) finally block: the consumer
|
||||
// calls MessageDone() AFTER it has finished reading the graph. This arms the
|
||||
// _readPos = -1 sentinel; the next AppendToBuffer for message 2 sees rp < 0 and recycles
|
||||
// the buffer to 0 (sliding-window cycling).
|
||||
input.MessageDone();
|
||||
|
||||
// Message 2 — same long-lived input, just keeps going
|
||||
input.Feed(WrapInChunkFrame([10, 20, 30, 40]));
|
||||
input.Feed([202]);
|
||||
|
||||
// Re-initialize for the next deserializer call — the buffer was recycled to 0 by the
|
||||
// sliding-window cycling triggered when AppendToBuffer saw _readPos == -1 (sentinel
|
||||
// armed by the MessageDone() call above).
|
||||
input.Initialize(out var buf2, out var pos2, out var bufLen2);
|
||||
Assert.AreEqual(4, bufLen2);
|
||||
Assert.AreEqual(10, buf2[0]);
|
||||
Assert.AreEqual(20, buf2[1]);
|
||||
Assert.AreEqual(30, buf2[2]);
|
||||
Assert.AreEqual(40, buf2[3]);
|
||||
|
||||
// Now signal end-of-session explicitly
|
||||
input.Complete();
|
||||
|
||||
// After Complete, TryAdvanceSegment returns false on empty — session truly ended
|
||||
var pos3 = bufLen2;
|
||||
var bufLen3 = bufLen2;
|
||||
var buf3 = buf2;
|
||||
var hasMore = input.TryAdvanceSegment(ref buf3, ref pos3, ref bufLen3, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_ExternalComplete_SignalsEndOfSession()
|
||||
{
|
||||
// Explicit Complete() (or stream-EOF in the DrainFromAsync path) is the session-end signal —
|
||||
// distinct from per-message [202] markers which only auto-reset for the next message.
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Complete(); // external session-end
|
||||
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
Assert.AreEqual(3, bufferLength);
|
||||
|
||||
position = bufferLength;
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_UnexpectedMarker_ThrowsInvalidDataException()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
// Byte 0x42 is not 200/201/202 — should throw
|
||||
_ = Assert.ThrowsExactly<InvalidDataException>(() => input.Feed([0x42]));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Step 2 — DrainFromAsync extension (ACCORE-BIN-T-M2K1)
|
||||
// ====================================================================
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await AsyncPipeReaderInputExtensions.DrainFromAsync(null!, pipe.Reader));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_NullReader_ThrowsArgumentNullException()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await input.DrainFromAsync(null!));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_PipeWithData_FeedsAllBytes()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
|
||||
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
await pipe.Writer.WriteAsync(WrapInChunkFrame(data));
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
await input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_EmptyPipeCompleted_CallsCompleteOnInput()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
await input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
// After drain, AsyncPipeReaderInput should be completed → TryAdvanceSegment returns false on empty
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_ConcurrentWriteDrainConsume_OrderPreserved()
|
||||
{
|
||||
// 3-thread pipeline: writer → pipe → drainer → input → consumer
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
|
||||
const int totalBytes = 4096;
|
||||
var expected = new byte[totalBytes];
|
||||
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
|
||||
|
||||
var consumeTask = Task.Run(() => ConsumeAll(input));
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
var writeTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const int chunkSize = 31;
|
||||
var offset = 0;
|
||||
while (offset < expected.Length)
|
||||
{
|
||||
var take = Math.Min(chunkSize, expected.Length - offset);
|
||||
await pipe.Writer.WriteAsync(WrapInChunkFrame(expected, offset, take));
|
||||
offset += take;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(consumeTask, drainTask, writeTask);
|
||||
|
||||
var actual = consumeTask.Result;
|
||||
CollectionAssert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_Cancellation_PropagatesAndCallsComplete()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader, cts.Token);
|
||||
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsExactlyAsync<OperationCanceledException>(async () => await drainTask);
|
||||
|
||||
// Verify Complete was called in the finally block
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Step 3 — Real parallel pipeline test (ACCORE-BIN-T-V7C9)
|
||||
//
|
||||
// True 3-task pipeline: AcBinarySerializer writes framed chunks to pipe.Writer via
|
||||
// AsyncPipeWriterOutput (framed mode under the hood) — drainer pulls from pipe.Reader
|
||||
// via DrainFromAsync — deserializer reads from AsyncPipeReaderInput (framing-aware Feed).
|
||||
// All three run concurrently with TRUE serialize↔deserialize overlap (the serializer is
|
||||
// still writing the tail of the message while the deserializer has already consumed the
|
||||
// head, courtesy of per-chunk SyncAwaitFlush in AsyncPipeWriterOutput).
|
||||
//
|
||||
// BufferWriterChunkSize = 256 → small payloads cross multiple [201][UINT16][data] chunk
|
||||
// boundaries on the wire, exercising the framing-aware AsyncPipeReaderInput.Feed state
|
||||
// machine. Wire is uniform AsyncSegment chunked format (per ADR-0003 §9).
|
||||
// ====================================================================
|
||||
|
||||
[TestMethod]
|
||||
public async Task RealParallelPipeline_SerializeViaPipeWriter_DeserializeViaPipeReader_PayloadEquals()
|
||||
{
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||||
var original = CreatePayload(50);
|
||||
|
||||
var pipe = new Pipe();
|
||||
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2);
|
||||
|
||||
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<TestParentWithDateTimeItemCollection>(input, opts));
|
||||
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
var serTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// SerializeChunkedFramed — writes [201][UINT16][data] per chunk on the wire.
|
||||
// AsyncPipeReaderInput.Feed strips framing internally on the receive side
|
||||
// (default multiMessage = true).
|
||||
AcBinarySerializer.SerializeChunkedFramed(original, pipe.Writer, opts);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(serTask, drainTask, deserTask);
|
||||
|
||||
var result = deserTask.Result;
|
||||
Assert.IsNotNull(result);
|
||||
AssertPayloadEquals(original, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RealParallelPipeline_LargeScalePayload_ChunkSize4096_StructuralEquality()
|
||||
{
|
||||
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
|
||||
// = ~3700 deeply-nested objects with shared references. Serialized size ~few hundred KB →
|
||||
// many chunks at chunkSize=4096 → real backpressure-driven streaming (PipeWriter pauseThreshold
|
||||
// ~64KB, bytes flow incrementally as drainer + deserializer task pulls them out).
|
||||
// This is the most-realistic real-parallel-pipeline test: in-memory Pipe + 3-task overlap +
|
||||
// production-scale payload + production-scale chunk size.
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 4096 };
|
||||
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
|
||||
|
||||
var pipe = new Pipe();
|
||||
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2);
|
||||
|
||||
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<TestOrder_All_True>(input, opts));
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader);
|
||||
var serTask = Task.Run(async () =>
|
||||
{
|
||||
try { AcBinarySerializer.SerializeChunkedFramed(original, pipe.Writer, opts); }
|
||||
finally { await pipe.Writer.CompleteAsync(); }
|
||||
});
|
||||
|
||||
await Task.WhenAll(serTask, drainTask, deserTask);
|
||||
|
||||
var result = deserTask.Result;
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(original.Status, result.Status);
|
||||
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
|
||||
|
||||
var origCounts = CountTestOrderHierarchy(original);
|
||||
var resultCounts = CountTestOrderHierarchy(result);
|
||||
|
||||
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
|
||||
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
|
||||
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
|
||||
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
|
||||
}
|
||||
|
||||
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
|
||||
{
|
||||
var items = order.Items.Count;
|
||||
int pallets = 0, measurements = 0, points = 0;
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
pallets += item.Pallets.Count;
|
||||
foreach (var p in item.Pallets)
|
||||
{
|
||||
measurements += p.Measurements.Count;
|
||||
points += p.Measurements.Sum(m => m.Points.Count);
|
||||
}
|
||||
}
|
||||
return (items, pallets, measurements, points);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Step 4 — AsyncPipeWriterOutput runtime type detect — sanity pinning
|
||||
// ====================================================================
|
||||
//
|
||||
// Guards the architectural assumption that PipeWriter.Create(Stream).GetType() resolves to a
|
||||
// different runtime type than new Pipe().Writer.GetType(). This is what makes
|
||||
// AsyncPipeWriterOutput._serializeFlushAndAcquire auto-select between sequential
|
||||
// (Stream-backed) and parallel (Pipe-based) flush strategies safe — without touching internal
|
||||
// BCL type names directly. If a future .NET unifies the two writer impls or renames the
|
||||
// internal type in a way that breaks the detect, these tests fail before prod.
|
||||
|
||||
[TestMethod]
|
||||
public void StreamPipeWriter_AndPipeWriter_AreDistinctTypes()
|
||||
{
|
||||
var pipeBased = new Pipe().Writer.GetType();
|
||||
var streamBased = PipeWriter.Create(Stream.Null).GetType();
|
||||
|
||||
// Cornerstone of the runtime detect — must NEVER unify, else _serializeFlushAndAcquire
|
||||
// would either always-true or always-false, both of which break correctness.
|
||||
Assert.AreNotEqual(pipeBased, streamBased,
|
||||
$"Runtime types unified — pipe-based and stream-backed PipeWriter must remain distinct. " +
|
||||
$"pipeBased={pipeBased.FullName}, streamBased={streamBased.FullName}");
|
||||
|
||||
// Living documentation — typenames printed for debugging on future .NET upgrades.
|
||||
Console.WriteLine($"Pipe.Writer typename: {pipeBased.FullName}");
|
||||
Console.WriteLine($"PipeWriter.Create(Stream) typename: {streamBased.FullName}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StreamPipeWriterTypeField_MatchesFactoryResult()
|
||||
{
|
||||
// The static field caches the StreamPipeWriter type via PipeWriter.Create(Stream.Null).GetType()
|
||||
// at class-load time. A second call to the factory MUST yield the same Type instance —
|
||||
// otherwise the cache is stale and the runtime detect mis-classifies all stream writers.
|
||||
var freshType = PipeWriter.Create(Stream.Null).GetType();
|
||||
|
||||
Assert.AreSame(freshType, AsyncPipeWriterOutput.StreamPipeWriterType,
|
||||
"Cached StreamPipeWriterType differs from a fresh factory result — the BCL is " +
|
||||
"behaving non-deterministically (or the test was loaded before AsyncPipeWriterOutput).");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAssignableFrom_PipeBasedWriter_ReturnsFalse()
|
||||
{
|
||||
// The Pipe.Writer impl must NOT be a StreamPipeWriter (or subclass thereof) — else
|
||||
// sequential mode would be wrongly selected and we'd lose the parallelism feature.
|
||||
var pipeBasedType = new Pipe().Writer.GetType();
|
||||
|
||||
Assert.IsFalse(AsyncPipeWriterOutput.StreamPipeWriterType.IsAssignableFrom(pipeBasedType),
|
||||
$"Pipe.Writer typename={pipeBasedType.FullName} is unexpectedly a StreamPipeWriter " +
|
||||
$"(or subclass) — runtime detect would mis-classify it as sequential.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAssignableFrom_StreamBackedWriters_ReturnsTrue()
|
||||
{
|
||||
// PipeWriter.Create(stream) must always yield a StreamPipeWriter (or subclass) —
|
||||
// even for unusual stream types (file, memory, null).
|
||||
Type[] writerTypes =
|
||||
[
|
||||
PipeWriter.Create(Stream.Null).GetType(),
|
||||
PipeWriter.Create(new MemoryStream()).GetType(),
|
||||
];
|
||||
|
||||
foreach (var t in writerTypes)
|
||||
{
|
||||
Assert.IsTrue(AsyncPipeWriterOutput.StreamPipeWriterType.IsAssignableFrom(t),
|
||||
$"PipeWriter.Create(<stream>) returned typename={t.FullName} which is not " +
|
||||
$"assignable to StreamPipeWriterType — the BCL changed its factory contract.");
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test helpers
|
||||
// ====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a raw payload in a single AsyncSegment chunk frame: <c>[201][UINT16 LE size][data]</c>.
|
||||
/// Matches the wire format produced by <see cref="AsyncPipeWriterOutput"/> per chunk.
|
||||
/// </summary>
|
||||
private static byte[] WrapInChunkFrame(byte[] data) => WrapInChunkFrame(data, 0, data.Length);
|
||||
|
||||
private static byte[] WrapInChunkFrame(byte[] data, int offset, int length)
|
||||
{
|
||||
var result = new byte[3 + length];
|
||||
|
||||
result[0] = 201; // CHUNK_DATA marker
|
||||
result[1] = (byte)(length & 0xFF); // UINT16 LE size, low byte
|
||||
result[2] = (byte)((length >> 8) & 0xFF); // UINT16 LE size, high byte
|
||||
|
||||
Array.Copy(data, offset, result, 3, length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains the input fully via the IBinaryInputBase contract, returning all consumed bytes.
|
||||
/// Mimics the consumer pattern that <c>DeserializeSequence<TInput></c> uses internally.
|
||||
/// </summary>
|
||||
private static byte[] ConsumeAll(AsyncPipeReaderInput input)
|
||||
{
|
||||
var consumed = new List<byte>();
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
|
||||
while (true)
|
||||
{
|
||||
while (position < bufferLength)
|
||||
{
|
||||
consumed.Add(buffer[position]);
|
||||
position++;
|
||||
}
|
||||
|
||||
if (!input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1))
|
||||
break;
|
||||
}
|
||||
|
||||
input.Release();
|
||||
return consumed.ToArray();
|
||||
}
|
||||
|
||||
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
|
||||
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
items.Add(new TestEntityWithDateTimeAndInt
|
||||
{
|
||||
Id = i + 1,
|
||||
IntValue = i * 3,
|
||||
Created = now.AddMinutes(-i),
|
||||
Modified = now.AddMinutes(i),
|
||||
StatusCode = i % 4,
|
||||
Name = $"item-{i}"
|
||||
});
|
||||
}
|
||||
|
||||
return new TestParentWithDateTimeItemCollection
|
||||
{
|
||||
Id = 11,
|
||||
Name = "real-parallel-pipeline",
|
||||
Created = now,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
|
||||
{
|
||||
Assert.AreEqual(expected.Id, actual.Id);
|
||||
Assert.AreEqual(expected.Name, actual.Name);
|
||||
Assert.AreEqual(expected.Created, actual.Created);
|
||||
|
||||
Assert.IsNotNull(expected.Items);
|
||||
Assert.IsNotNull(actual.Items);
|
||||
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
|
||||
|
||||
for (var i = 0; i < expected.Items.Count; i++)
|
||||
{
|
||||
var e = expected.Items[i];
|
||||
var a = actual.Items[i];
|
||||
|
||||
Assert.AreEqual(e.Id, a.Id);
|
||||
Assert.AreEqual(e.IntValue, a.IntValue);
|
||||
Assert.AreEqual(e.Created, a.Created);
|
||||
Assert.AreEqual(e.Modified, a.Modified);
|
||||
Assert.AreEqual(e.StatusCode, a.StatusCode);
|
||||
Assert.AreEqual(e.Name, a.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
[TestClass]
|
||||
public class AcBinarySerializerSGenNullComplexPropertyTests
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenComplexPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullComplexParent
|
||||
{
|
||||
Id = 7,
|
||||
Customer = null!,
|
||||
Note = "regression"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullComplexParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNull(roundTrip.Customer,
|
||||
"complex reference property must round-trip as null when source was null " +
|
||||
"(regression for SGen WriteObjectGenerated fallback else-branch null-check)");
|
||||
|
||||
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
|
||||
"writer must emit PropertySkip marker on the null Customer slot " +
|
||||
"(deeper verification: confirms the fix took the PropertySkip path, " +
|
||||
"not an unrelated null-safe code path)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenComplexPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullComplexParent
|
||||
{
|
||||
Id = 13,
|
||||
Customer = new NonGeneratedComplexCustomer { Id = 42, Name = "child" },
|
||||
Note = "positive"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullComplexParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNotNull(roundTrip.Customer,
|
||||
"non-null complex reference property must round-trip (null-check fix must not break the non-null path)");
|
||||
Assert.AreEqual(model.Customer.Id, roundTrip.Customer.Id);
|
||||
Assert.AreEqual(model.Customer.Name, roundTrip.Customer.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenCollectionPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullCollectionParent
|
||||
{
|
||||
Id = 11,
|
||||
Items = null!,
|
||||
Note = "regression-collection"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullCollectionParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNull(roundTrip.Items,
|
||||
"collection property (with non-SGen element type) must round-trip as null when source was null " +
|
||||
"(regression for SGen Collection fallback WriteValueGenerated else-branch null-check)");
|
||||
|
||||
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
|
||||
"writer must emit PropertySkip marker on the null Items slot " +
|
||||
"(confirms the fix took the PropertySkip path, not an unrelated null-safe code path)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenCollectionPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullCollectionParent
|
||||
{
|
||||
Id = 17,
|
||||
Items = new List<NonGeneratedComplexCustomer>
|
||||
{
|
||||
new() { Id = 1, Name = "first" },
|
||||
new() { Id = 2, Name = "second" }
|
||||
},
|
||||
Note = "positive-collection"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullCollectionParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNotNull(roundTrip.Items,
|
||||
"non-null collection property must round-trip (null-check fix must not break the non-null path)");
|
||||
Assert.AreEqual(model.Items.Count, roundTrip.Items.Count);
|
||||
Assert.AreEqual(model.Items[0].Id, roundTrip.Items[0].Id);
|
||||
Assert.AreEqual(model.Items[0].Name, roundTrip.Items[0].Name);
|
||||
Assert.AreEqual(model.Items[1].Id, roundTrip.Items[1].Id);
|
||||
Assert.AreEqual(model.Items[1].Name, roundTrip.Items[1].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenDictionaryPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullDictionaryParent
|
||||
{
|
||||
Id = 23,
|
||||
Mapping = null!,
|
||||
Note = "regression-dictionary"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullDictionaryParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNull(roundTrip.Mapping,
|
||||
"dictionary property must round-trip as null when source was null " +
|
||||
"(pins EmitDirectDictionaryWrite line ~1037 null-check against future regression)");
|
||||
|
||||
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
|
||||
"writer must emit PropertySkip marker on the null Mapping slot " +
|
||||
"(confirms the PropertySkip path, not an unrelated null-safe code path)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenDictionaryPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullDictionaryParent
|
||||
{
|
||||
Id = 29,
|
||||
Mapping = new Dictionary<string, NonGeneratedComplexCustomer>
|
||||
{
|
||||
["alpha"] = new() { Id = 1, Name = "first" },
|
||||
["beta"] = new() { Id = 2, Name = "second" }
|
||||
},
|
||||
Note = "positive-dictionary"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullDictionaryParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNotNull(roundTrip.Mapping,
|
||||
"non-null dictionary property must round-trip (null-check pin must not break the non-null path)");
|
||||
Assert.AreEqual(model.Mapping.Count, roundTrip.Mapping.Count);
|
||||
Assert.AreEqual(model.Mapping["alpha"].Id, roundTrip.Mapping["alpha"].Id);
|
||||
Assert.AreEqual(model.Mapping["alpha"].Name, roundTrip.Mapping["alpha"].Name);
|
||||
Assert.AreEqual(model.Mapping["beta"].Id, roundTrip.Mapping["beta"].Id);
|
||||
Assert.AreEqual(model.Mapping["beta"].Name, roundTrip.Mapping["beta"].Name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
[TestClass]
|
||||
public class AcBinarySerializerSGenRuntimeCompatibilityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions StjOptions = new()
|
||||
{
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
|
||||
[TestMethod]
|
||||
public void SerializeWithSGen_DeserializeWithRuntime_LargeAndDeepData_MultipleOptions_RoundTrip()
|
||||
{
|
||||
foreach (var dataSet in GetTargetDataSets())
|
||||
{
|
||||
foreach (var optionFactory in GetOptionFactories())
|
||||
{
|
||||
var serializeOptions = optionFactory();
|
||||
serializeOptions.UseGeneratedCode = true;
|
||||
|
||||
var deserializeOptions = optionFactory();
|
||||
deserializeOptions.UseGeneratedCode = false;
|
||||
|
||||
var expectedJson = JsonSerializer.Serialize(dataSet.Order, StjOptions);
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(dataSet.Order, serializeOptions);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, deserializeOptions);
|
||||
var actualJson = JsonSerializer.Serialize(roundTrip, StjOptions);
|
||||
|
||||
Assert.AreEqual(expectedJson, actualJson, $"STJ mismatch. Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
|
||||
|
||||
AssertOrderEquivalent(dataSet.Order, roundTrip, $"Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SerializeWithRuntime_DeserializeWithSGen_LargeAndDeepData_MultipleOptions_RoundTrip()
|
||||
{
|
||||
foreach (var dataSet in GetTargetDataSets())
|
||||
{
|
||||
foreach (var optionFactory in GetOptionFactories())
|
||||
{
|
||||
var serializeOptions = optionFactory();
|
||||
serializeOptions.UseGeneratedCode = false;
|
||||
|
||||
var deserializeOptions = optionFactory();
|
||||
deserializeOptions.UseGeneratedCode = true;
|
||||
|
||||
var expectedJson = JsonSerializer.Serialize(dataSet.Order, StjOptions);
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(dataSet.Order, serializeOptions);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, deserializeOptions);
|
||||
var actualJson = JsonSerializer.Serialize(roundTrip, StjOptions);
|
||||
|
||||
Assert.AreEqual(expectedJson, actualJson, $"STJ mismatch. Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
|
||||
|
||||
AssertOrderEquivalent(dataSet.Order, roundTrip, $"Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test: SGen ↔ SGen round-trip with non-ASCII multi-byte ProductName above the
|
||||
/// StringSmall threshold (utf8Len > 255 byte). Engages the StringMedium tier (marker 94,
|
||||
/// fixed-width header [marker:1][charLen:16][utf8Len:16][bytes]). After ProductName in
|
||||
/// TestOrderItemBase come Quantity (int) + UnitPrice (decimal) — any writer/reader byte-count
|
||||
/// asymmetry in the StringMedium path surfaces as a UnitPrice corruption (DECIMAL_DRIFT) or
|
||||
/// Quantity skew. The [AcStringIntern(true)] attribute on ProductName means the first occurrence
|
||||
/// emits StringInternFirstMedium (marker 105) for the InternFirst tier.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Serialize_MediumStringUtf8_OnProductName_SGenRoundTrip()
|
||||
{
|
||||
// 300 chars × 2 byte (Hungarian 'á' = 2 byte UTF-8) = 600 byte UTF-8 → StringMedium (or
|
||||
// StringInternFirstMedium for the first occurrence under interning).
|
||||
var mediumUtf8 = new string('á', 300);
|
||||
|
||||
foreach (var optionFactory in GetOptionFactories())
|
||||
{
|
||||
var options = optionFactory();
|
||||
options.UseGeneratedCode = true;
|
||||
|
||||
var order = BenchmarkTestDataProvider
|
||||
.CreateTestDataSets()
|
||||
.Cast<TestDataSet<TestOrder_All_True>>()
|
||||
.First(x => x.Name.StartsWith("Small")).Order;
|
||||
|
||||
foreach (var item in order.Items) item.ProductName = mediumUtf8;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(order, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
|
||||
|
||||
AssertOrderEquivalent(order, roundTrip,
|
||||
$"WireMode={options.WireMode}, Refs={options.ReferenceHandling}, Interning={options.UseStringInterning}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test: SGen ↔ SGen round-trip with pure ASCII ProductName above the FixStrAscii inline
|
||||
/// limit (>31 chars). Engages StringAscii (marker 167) — writer detects ASCII via
|
||||
/// bytesWritten == charLength post-encode, reader byte→char widens directly without UTF-8 decode.
|
||||
/// Same drift-surface as the UTF-8 variant: UnitPrice / Quantity after ProductName in TestOrderItemBase.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Serialize_MediumStringAscii_OnProductName_SGenRoundTrip()
|
||||
{
|
||||
// 500 chars × 1 byte = 500 byte ASCII → StringAscii (167) tier.
|
||||
var mediumAscii = new string('X', 500);
|
||||
|
||||
foreach (var optionFactory in GetOptionFactories())
|
||||
{
|
||||
var options = optionFactory();
|
||||
options.UseGeneratedCode = true;
|
||||
|
||||
var order = BenchmarkTestDataProvider
|
||||
.CreateTestDataSets()
|
||||
.Cast<TestDataSet<TestOrder_All_True>>()
|
||||
.First(x => x.Name.StartsWith("Small")).Order;
|
||||
|
||||
foreach (var item in order.Items) item.ProductName = mediumAscii;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(order, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
|
||||
|
||||
AssertOrderEquivalent(order, roundTrip,
|
||||
$"WireMode={options.WireMode}, Refs={options.ReferenceHandling}, Interning={options.UseStringInterning}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<TestDataSet<TestOrder_All_True>> GetTargetDataSets()
|
||||
{
|
||||
// SGen↔Runtime compatibility test depends on TestOrder_All_True graphs (the AssertOrderEquivalent
|
||||
// signature + JSON canonicalisation are typed for _All_True). The bare-name BenchmarkTestDataProvider
|
||||
// alias closes the generic provider on _All_True — Phase 1 benchmark uses the sibling
|
||||
// BenchmarkTestDataProvider_All_False alias instead.
|
||||
return BenchmarkTestDataProvider
|
||||
.CreateTestDataSets()
|
||||
.Cast<TestDataSet<TestOrder_All_True>>()
|
||||
.Where(x => x.Name.StartsWith("Large") || x.Name.StartsWith("Deep"));
|
||||
}
|
||||
|
||||
private static IEnumerable<Func<AcBinarySerializerOptions>> GetOptionFactories()
|
||||
{
|
||||
yield return static () =>
|
||||
{
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
return options;
|
||||
};
|
||||
|
||||
yield return static () =>
|
||||
{
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Fast;
|
||||
return options;
|
||||
};
|
||||
|
||||
yield return static () =>
|
||||
{
|
||||
var options = AcBinarySerializerOptions.Default;
|
||||
options.WireMode = WireMode.Compact;
|
||||
return options;
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertOrderEquivalent(TestOrder_All_True expected, TestOrder_All_True? actual, string context)
|
||||
{
|
||||
Assert.IsNotNull(actual, context);
|
||||
Assert.AreEqual(expected.Id, actual.Id, context);
|
||||
Assert.AreEqual(expected.OrderNumber, actual.OrderNumber, context);
|
||||
Assert.AreEqual(expected.Status, actual.Status, context);
|
||||
Assert.AreEqual(expected.Items.Count, actual.Items.Count, context);
|
||||
|
||||
for (var itemIndex = 0; itemIndex < expected.Items.Count; itemIndex++)
|
||||
{
|
||||
var expectedItem = expected.Items[itemIndex];
|
||||
var actualItem = actual.Items[itemIndex];
|
||||
|
||||
Assert.AreEqual(expectedItem.Id, actualItem.Id, context);
|
||||
Assert.AreEqual(expectedItem.ProductName, actualItem.ProductName, context);
|
||||
Assert.AreEqual(expectedItem.Status, actualItem.Status, context);
|
||||
Assert.AreEqual(expectedItem.Pallets.Count, actualItem.Pallets.Count, context);
|
||||
|
||||
for (var palletIndex = 0; palletIndex < expectedItem.Pallets.Count; palletIndex++)
|
||||
{
|
||||
var expectedPallet = expectedItem.Pallets[palletIndex];
|
||||
var actualPallet = actualItem.Pallets[palletIndex];
|
||||
|
||||
Assert.AreEqual(expectedPallet.Id, actualPallet.Id, context);
|
||||
Assert.AreEqual(expectedPallet.PalletCode, actualPallet.PalletCode, context);
|
||||
Assert.AreEqual(expectedPallet.Measurements.Count, actualPallet.Measurements.Count, context);
|
||||
|
||||
for (var measurementIndex = 0; measurementIndex < expectedPallet.Measurements.Count; measurementIndex++)
|
||||
{
|
||||
var expectedMeasurement = expectedPallet.Measurements[measurementIndex];
|
||||
var actualMeasurement = actualPallet.Measurements[measurementIndex];
|
||||
|
||||
Assert.AreEqual(expectedMeasurement.Id, actualMeasurement.Id, context);
|
||||
Assert.AreEqual(expectedMeasurement.Name, actualMeasurement.Name, context);
|
||||
Assert.AreEqual(expectedMeasurement.Points.Count, actualMeasurement.Points.Count, context);
|
||||
|
||||
for (var pointIndex = 0; pointIndex < expectedMeasurement.Points.Count; pointIndex++)
|
||||
{
|
||||
var expectedPoint = expectedMeasurement.Points[pointIndex];
|
||||
var actualPoint = actualMeasurement.Points[pointIndex];
|
||||
|
||||
Assert.AreEqual(expectedPoint.Id, actualPoint.Id, context);
|
||||
Assert.AreEqual(expectedPoint.Label, actualPoint.Label, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ public class AcExpressionNodeSerializationTests
|
|||
public void AcJsonSerializer_WithAcExpressionNode_RoundTrip_Works()
|
||||
{
|
||||
// Arrange - Create an expression with a constant value
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
|
||||
item => item.Quantity > 5;
|
||||
|
||||
var expressionNode = AcExpressionConverter.ToNode(filterExpression);
|
||||
|
|
@ -39,11 +39,11 @@ public class AcExpressionNodeSerializationTests
|
|||
Assert.IsNotNull(deserialized, "Deserialized node should not be null");
|
||||
|
||||
// Rebuild and test
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserialized);
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserialized);
|
||||
var compiled = rebuiltExpression.Compile();
|
||||
|
||||
var matchingItem = new TestOrderItem { Id = 1, Quantity = 10 };
|
||||
var nonMatchingItem = new TestOrderItem { Id = 2, Quantity = 3 };
|
||||
var matchingItem = new TestOrderItem_All_True { Id = 1, Quantity = 10 };
|
||||
var nonMatchingItem = new TestOrderItem_All_True { Id = 2, Quantity = 3 };
|
||||
|
||||
Assert.IsTrue(compiled(matchingItem), "Matching item should pass filter");
|
||||
Assert.IsFalse(compiled(nonMatchingItem), "Non-matching item should fail filter");
|
||||
|
|
@ -121,7 +121,7 @@ public class AcExpressionNodeSerializationTests
|
|||
public void AcBinarySerializer_WithAcExpressionNode_RoundTrip_Works()
|
||||
{
|
||||
// Arrange
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
|
||||
item => item.Quantity > 5;
|
||||
|
||||
var originalNode = AcExpressionConverter.ToNode(filterExpression);
|
||||
|
|
@ -137,11 +137,11 @@ public class AcExpressionNodeSerializationTests
|
|||
Assert.AreEqual(originalNode.NodeType, deserialized.NodeType);
|
||||
|
||||
// Rebuild and test
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserialized);
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserialized);
|
||||
var compiled = rebuiltExpression.Compile();
|
||||
|
||||
var matchingItem = new TestOrderItem { Id = 1, Quantity = 10 };
|
||||
var nonMatchingItem = new TestOrderItem { Id = 2, Quantity = 3 };
|
||||
var matchingItem = new TestOrderItem_All_True { Id = 1, Quantity = 10 };
|
||||
var nonMatchingItem = new TestOrderItem_All_True { Id = 2, Quantity = 3 };
|
||||
|
||||
Assert.IsTrue(compiled(matchingItem), "Matching item should pass filter");
|
||||
Assert.IsFalse(compiled(nonMatchingItem), "Non-matching item should fail filter");
|
||||
|
|
@ -183,7 +183,7 @@ public class AcExpressionNodeSerializationTests
|
|||
{
|
||||
// Arrange - Expression with captured decimal: item => item.UnitPrice > 99.99m
|
||||
var minPrice = 99.99m;
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
|
||||
item => item.UnitPrice > minPrice;
|
||||
|
||||
var originalNode = AcExpressionConverter.ToNode(filterExpression);
|
||||
|
|
@ -195,11 +195,11 @@ public class AcExpressionNodeSerializationTests
|
|||
// Assert - Rebuild and verify it still works with decimal comparison
|
||||
Assert.IsNotNull(deserializedNode);
|
||||
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserializedNode);
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserializedNode);
|
||||
var compiledFilter = rebuiltExpression.Compile();
|
||||
|
||||
var expensiveItem = new TestOrderItem { UnitPrice = 150m };
|
||||
var cheapItem = new TestOrderItem { UnitPrice = 50m };
|
||||
var expensiveItem = new TestOrderItem_All_True { UnitPrice = 150m };
|
||||
var cheapItem = new TestOrderItem_All_True { UnitPrice = 50m };
|
||||
|
||||
Assert.IsTrue(compiledFilter(expensiveItem), "Expensive item should pass filter");
|
||||
Assert.IsFalse(compiledFilter(cheapItem), "Cheap item should fail filter");
|
||||
|
|
@ -212,7 +212,7 @@ public class AcExpressionNodeSerializationTests
|
|||
public void AcBinarySerializer_WithEnumValue_PreservesType()
|
||||
{
|
||||
// Arrange - Expression with enum comparison
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
|
||||
item => item.Status == TestStatus.Completed;
|
||||
|
||||
var originalNode = AcExpressionConverter.ToNode(filterExpression);
|
||||
|
|
@ -224,11 +224,11 @@ public class AcExpressionNodeSerializationTests
|
|||
// Assert
|
||||
Assert.IsNotNull(deserializedNode);
|
||||
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserializedNode);
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserializedNode);
|
||||
var compiledFilter = rebuiltExpression.Compile();
|
||||
|
||||
var completedItem = new TestOrderItem { Status = TestStatus.Completed };
|
||||
var pendingItem = new TestOrderItem { Status = TestStatus.Pending };
|
||||
var completedItem = new TestOrderItem_All_True { Status = TestStatus.Completed };
|
||||
var pendingItem = new TestOrderItem_All_True { Status = TestStatus.Pending };
|
||||
|
||||
Assert.IsTrue(compiledFilter(completedItem), "Completed item should pass filter");
|
||||
Assert.IsFalse(compiledFilter(pendingItem), "Pending item should fail filter");
|
||||
|
|
|
|||
|
|
@ -46,25 +46,25 @@ public class AcJsonSerializerIIdReferenceTests
|
|||
public void SameInstance_Json_SerializeAndDeserialize()
|
||||
{
|
||||
// Arrange: SAME instance used 4 times
|
||||
var sharedTag = new SharedTag { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
|
||||
var sharedTag = new SharedTag_All_True { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
PrimaryTag = sharedTag,
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem { Id = 1, ProductName = "Product-A", Tag = sharedTag },
|
||||
new TestOrderItem { Id = 2, ProductName = "Product-B", Tag = sharedTag },
|
||||
new TestOrderItem { Id = 3, ProductName = "Product-C", Tag = sharedTag }
|
||||
new TestOrderItem_All_True { Id = 1, ProductName = "Product-A", Tag = sharedTag },
|
||||
new TestOrderItem_All_True { Id = 2, ProductName = "Product-B", Tag = sharedTag },
|
||||
new TestOrderItem_All_True { Id = 3, ProductName = "Product-C", Tag = sharedTag }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = order.ToJson();
|
||||
Console.WriteLine(json);
|
||||
var result = json.JsonTo<TestOrder>();
|
||||
var result = json.JsonTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: JSON contains $ref markers (reference handling is active)
|
||||
var refCount = CountOccurrences(json, "{\"$ref\":\"1\"}");
|
||||
|
|
@ -118,43 +118,43 @@ public class AcJsonSerializerIIdReferenceTests
|
|||
{
|
||||
// Arrange: DIFFERENT instances but SAME IId.Id
|
||||
// CRITICAL: Multiple DIFFERENT TYPES all have Id=1 - must not be confused!
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
// All three types have Id=1 - tests (Type, Id) keying, not just Id
|
||||
PrimaryTag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Owner = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
|
||||
Category = new SharedCategory { Id = 1, Name = "Category_Id1", SortOrder = 10 },
|
||||
PrimaryTag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Owner = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
|
||||
Category = new SharedCategory_All_True { Id = 1, Name = "Category_Id1", SortOrder = 10 },
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 1,
|
||||
ProductName = "Product-A",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
},
|
||||
new TestOrderItem
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 2,
|
||||
ProductName = "Product-B",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
},
|
||||
new TestOrderItem
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 3,
|
||||
ProductName = "Product-C",
|
||||
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = order.ToJson();
|
||||
var result = json.JsonTo<TestOrder>();
|
||||
var result = json.JsonTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: Check if $ref is used (IId-based deduplication active)
|
||||
var refCount = CountOccurrences(json, "\"$ref\"");
|
||||
|
|
@ -208,11 +208,11 @@ public class AcJsonSerializerIIdReferenceTests
|
|||
// Assert 3: Reference identity - same TYPE with same Id should be same reference
|
||||
// Tags with Id=1 should all be same reference
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag,
|
||||
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
|
||||
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[1].Tag,
|
||||
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
|
||||
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[2].Tag,
|
||||
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
|
||||
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
|
||||
// Users with Id=1 should all be same reference
|
||||
Assert.AreSame(result.Owner, result.Items[0].Assignee,
|
||||
|
|
@ -238,36 +238,36 @@ public class AcJsonSerializerIIdReferenceTests
|
|||
public void DifferentInstances_SameIId_SmallerJsonWithDataIntegrity()
|
||||
{
|
||||
// Arrange: 10 different instances with SAME IId
|
||||
var orderWithSameIId = new TestOrder
|
||||
var orderWithSameIId = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "SAME-IID",
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
|
||||
{
|
||||
Id = i,
|
||||
ProductName = $"Product-{i}",
|
||||
Assignee = new SharedUser { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Arrange: 10 different instances with DIFFERENT IIds
|
||||
var orderWithDifferentIIds = new TestOrder
|
||||
var orderWithDifferentIIds = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "DIFF-IID",
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
|
||||
{
|
||||
Id = i,
|
||||
ProductName = $"Product-{i}",
|
||||
Assignee = new SharedUser { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
|
||||
Assignee = new SharedUser_All_True { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Act
|
||||
var sameIIdJson = orderWithSameIId.ToJson();
|
||||
var diffIIdJson = orderWithDifferentIIds.ToJson();
|
||||
var sameIIdResult = sameIIdJson.JsonTo<TestOrder>();
|
||||
var diffIIdResult = diffIIdJson.JsonTo<TestOrder>();
|
||||
var sameIIdResult = sameIIdJson.JsonTo<TestOrder_All_True>();
|
||||
var diffIIdResult = diffIIdJson.JsonTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: Size comparison
|
||||
Console.WriteLine($"Same IId JSON size: {sameIIdJson.Length} chars");
|
||||
|
|
@ -416,7 +416,7 @@ public class AcJsonSerializerIIdReferenceTests
|
|||
[TestMethod]
|
||||
public void SharedCategory_DataIntegrity()
|
||||
{
|
||||
var categories = new List<SharedCategory>
|
||||
var categories = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1, IsDefault = true },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2, ParentCategoryId = 1 },
|
||||
|
|
@ -424,7 +424,7 @@ public class AcJsonSerializerIIdReferenceTests
|
|||
};
|
||||
|
||||
var json = categories.ToJson();
|
||||
var result = json.JsonTo<List<SharedCategory>>();
|
||||
var result = json.JsonTo<List<SharedCategory_All_True>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using System;
|
||||
using System.IO.Pipelines;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Test/benchmark-only extension methods for populating <see cref="AsyncPipeReaderInput"/>
|
||||
/// from <see cref="System.IO.Pipelines.PipeReader"/>-backed transports (NamedPipe, FileStream,
|
||||
/// custom pipe sources).
|
||||
///
|
||||
/// <para><b>Why test-only:</b> in real production, the consuming application already has its own
|
||||
/// reader-task that reads from the pipe and pushes bytes via <c>AsyncPipeReaderInput.Feed</c>
|
||||
/// — providing this drain extension publicly would duplicate that responsibility and confuse
|
||||
/// the canonical push-pattern. The extension is kept here for unit-test scaffolding and the
|
||||
/// streaming benchmark; production NuGet consumers should write their own drain logic in their
|
||||
/// own reader-task following the application's threading model.</para>
|
||||
/// </summary>
|
||||
public static class AsyncPipeReaderInputExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Drains a <see cref="PipeReader"/> end-to-end into the <see cref="AsyncPipeReaderInput"/>:
|
||||
/// calls <see cref="AsyncPipeReaderInput.Feed"/> on each segment and
|
||||
/// <see cref="AsyncPipeReaderInput.Complete"/> when the pipe completes.
|
||||
///
|
||||
/// <para>Typical usage (test-only): NamedPipe IPC and FileStream-via-PipeReader transports
|
||||
/// schedule this on a background task while the deserialization context reads from the same
|
||||
/// input on another thread.</para>
|
||||
///
|
||||
/// <para><see cref="AsyncPipeReaderInput.Complete"/> is invoked in a <c>finally</c> block —
|
||||
/// ensures the consumer always wakes up even if the pipe read throws or the operation is
|
||||
/// cancelled. Exceptions (including <see cref="OperationCanceledException"/>) propagate to
|
||||
/// the caller after <c>Complete</c> runs.</para>
|
||||
/// </summary>
|
||||
/// <param name="input">The receive-side input to feed.</param>
|
||||
/// <param name="reader">The pipe reader to drain.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token.</param>
|
||||
/// <exception cref="ArgumentNullException">If <paramref name="input"/> or <paramref name="reader"/> is <c>null</c>.</exception>
|
||||
public static async Task DrainFromAsync(this AsyncPipeReaderInput input, PipeReader reader, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (input is null) throw new ArgumentNullException(nameof(input));
|
||||
if (reader is null) throw new ArgumentNullException(nameof(reader));
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var segment in result.Buffer) input.Feed(segment.Span);
|
||||
|
||||
reader.AdvanceTo(result.Buffer.End);
|
||||
if (result.IsCompleted) break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ public class ChainReferenceDebugTest
|
|||
// Test ChainReferenceTracker directly
|
||||
var tracker = new AcSerializerCommon.ChainReferenceTracker();
|
||||
|
||||
var category = new SharedCategory { Id = 100, Name = "TestCategory" };
|
||||
var category = new SharedCategory_All_True { Id = 100, Name = "TestCategory" };
|
||||
|
||||
// Register using reflection (like ThenPopulate does)
|
||||
tracker.TryRegisterIIdObject(category);
|
||||
|
|
@ -32,17 +32,17 @@ public class ChainReferenceDebugTest
|
|||
[TestMethod]
|
||||
public void DebugSimpleChainPopulate()
|
||||
{
|
||||
var list1 = new List<SharedCategory>();
|
||||
var list2 = new List<SharedCategory>();
|
||||
var list1 = new List<SharedCategory_All_True>();
|
||||
var list2 = new List<SharedCategory_All_True>();
|
||||
|
||||
var serverData = new List<SharedCategory>
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Cat1", SortOrder = 10 }
|
||||
};
|
||||
|
||||
var binary = serverData.ToBinary();
|
||||
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory>>();
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// First populate
|
||||
chain.ThenPopulate(list1);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ public class GeneratedSerializerIntegrationTests
|
|||
public void GeneratedWriter_ComplexHierarchy_RoundTrip()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
|
||||
var order = TestDataFactory.CreateOrder(
|
||||
|
|
@ -116,7 +116,7 @@ public class GeneratedSerializerIntegrationTests
|
|||
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
var bytes = AcBinarySerializer.Serialize(order, options);
|
||||
var deserialized = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, options);
|
||||
var deserialized = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(deserialized);
|
||||
Assert.AreEqual(order.Id, deserialized.Id);
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ public class QuickBenchmark
|
|||
Console.WriteLine($"[WARN] Deserialize: AcBinary is {deserRatio:F2}x slower");
|
||||
}
|
||||
|
||||
private static TestOrder CreatePopulateTarget(TestOrder source)
|
||||
private static TestOrder_All_True CreatePopulateTarget(TestOrder_All_True source)
|
||||
{
|
||||
var target = new TestOrder { Id = source.Id };
|
||||
var target = new TestOrder_All_True { Id = source.Id };
|
||||
foreach (var item in source.Items)
|
||||
{
|
||||
target.Items.Add(new TestOrderItem { Id = item.Id });
|
||||
target.Items.Add(new TestOrderItem_All_True { Id = item.Id });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ public class QuickBenchmark
|
|||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var bytes = order.ToBinary();
|
||||
var result = bytes.BinaryTo<TestOrder>();
|
||||
var result = bytes.BinaryTo<TestOrder_All_True>();
|
||||
}
|
||||
|
||||
// Measure serialize
|
||||
|
|
@ -121,10 +121,10 @@ public class QuickBenchmark
|
|||
|
||||
// Measure deserialize
|
||||
sw.Restart();
|
||||
TestOrder? deserialized = null;
|
||||
TestOrder_All_True? deserialized = null;
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
deserialized = serialized.BinaryTo<TestOrder>();
|
||||
deserialized = serialized.BinaryTo<TestOrder_All_True>();
|
||||
}
|
||||
sw.Stop();
|
||||
var deserializeMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
|
@ -143,7 +143,7 @@ public class QuickBenchmark
|
|||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var _ = json.JsonTo<TestOrder>(jsonOptions);
|
||||
var _ = json.JsonTo<TestOrder_All_True>(jsonOptions);
|
||||
}
|
||||
sw.Stop();
|
||||
var jsonDeserializeMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
|
@ -234,9 +234,9 @@ public class QuickBenchmark
|
|||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var binBytes = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
|
||||
var binResult = AcBinaryDeserializer.Deserialize<TestOrder>(binBytes);
|
||||
var binResult = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binBytes);
|
||||
var msgBytes = MessagePackSerializer.Serialize(order, MsgPackOptions);
|
||||
var msgResult = MessagePackSerializer.Deserialize<TestOrder>(msgBytes, MsgPackOptions);
|
||||
var msgResult = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgBytes, MsgPackOptions);
|
||||
}
|
||||
|
||||
const int iterations = DefaultIterations;
|
||||
|
|
@ -263,20 +263,20 @@ public class QuickBenchmark
|
|||
|
||||
// === AcBinary Deserialize ===
|
||||
sw.Restart();
|
||||
TestOrder? acBinaryResult = null;
|
||||
TestOrder_All_True? acBinaryResult = null;
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
acBinaryResult = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryData);
|
||||
acBinaryResult = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryData);
|
||||
}
|
||||
sw.Stop();
|
||||
var acBinaryDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// === MessagePack Deserialize ===
|
||||
sw.Restart();
|
||||
TestOrder? msgPackResult = null;
|
||||
TestOrder_All_True? msgPackResult = null;
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
msgPackResult = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
|
||||
msgPackResult = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, MsgPackOptions);
|
||||
}
|
||||
sw.Stop();
|
||||
var msgPackDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
|
@ -382,7 +382,7 @@ public class QuickBenchmark
|
|||
public void GetAnalyzeStringInternCandidatesLog()
|
||||
{
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
|
|
@ -413,7 +413,7 @@ public class QuickBenchmark
|
|||
{
|
||||
// Create test data with shared references
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
|
|
@ -492,7 +492,7 @@ public class QuickBenchmark
|
|||
|
||||
// Create test data with shared references
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
|
|
@ -532,10 +532,10 @@ public class QuickBenchmark
|
|||
_ = MessagePackSerializer.Serialize(testOrder, MsgPackOptions);
|
||||
|
||||
Console.WriteLine("acBinaryWithRef");
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
|
||||
Console.WriteLine("acBinaryNoRef");
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, MsgPackOptions);
|
||||
}
|
||||
|
||||
// Wait for tiered JIT background compilation to complete
|
||||
|
|
@ -573,19 +573,19 @@ public class QuickBenchmark
|
|||
// === Deserialize WithRef ===
|
||||
sw.Restart();
|
||||
for (int i = 0; i < DefaultIterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
|
||||
var acWithRefDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// === Deserialize NoRef ===
|
||||
sw.Restart();
|
||||
for (int i = 0; i < DefaultIterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
|
||||
var acNoRefDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// === MessagePack Deserialize ===
|
||||
sw.Restart();
|
||||
for (int i = 0; i < DefaultIterations; i++)
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, MsgPackOptions);
|
||||
var msgPackDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// === Populate (AcBinary only) ===
|
||||
|
|
@ -602,7 +602,7 @@ public class QuickBenchmark
|
|||
for (int i = 0; i < DefaultIterations; i++)
|
||||
{
|
||||
var target = CreatePopulateTarget(testOrder);
|
||||
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef.AsSpan(), target);
|
||||
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef, target);
|
||||
}
|
||||
var acMergeMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
|
|
@ -632,7 +632,7 @@ public class QuickBenchmark
|
|||
|
||||
// Create test data WITH shared references (to show WithRef advantage)
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag");
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
|
|
@ -685,13 +685,13 @@ public class QuickBenchmark
|
|||
// Deserialize WithRef
|
||||
sw.Restart();
|
||||
for (int i = 0; i < DefaultIterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(withRefData);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(withRefData);
|
||||
var withRefDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// Deserialize NoRef
|
||||
sw.Restart();
|
||||
for (int i = 0; i < DefaultIterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(noRefData);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(noRefData);
|
||||
var noRefDeserMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
PrintBanner("PERFORMANCE COMPARISON (ms)");
|
||||
|
|
@ -709,8 +709,8 @@ public class QuickBenchmark
|
|||
}
|
||||
|
||||
// Verify correctness
|
||||
var resultWithRef = AcBinaryDeserializer.Deserialize<TestOrder>(withRefData);
|
||||
var resultNoRef = AcBinaryDeserializer.Deserialize<TestOrder>(noRefData);
|
||||
var resultWithRef = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(withRefData);
|
||||
var resultNoRef = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(noRefData);
|
||||
Assert.IsNotNull(resultWithRef);
|
||||
Assert.IsNotNull(resultNoRef);
|
||||
Assert.AreEqual(testOrder.Id, resultWithRef.Id);
|
||||
|
|
@ -744,7 +744,7 @@ public class QuickBenchmark
|
|||
AcBinaryDeserializer.Populate(binaryData, target);
|
||||
|
||||
//Console.WriteLine("PopulateMerge");
|
||||
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target);
|
||||
AcBinaryDeserializer.PopulateMerge(binaryData, target);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Iterations: {DefaultIterations:N0}");
|
||||
|
|
@ -755,7 +755,7 @@ public class QuickBenchmark
|
|||
// Deserialize (creates new object)
|
||||
sw.Restart();
|
||||
for (int i = 0; i < DefaultIterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(binaryData);
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryData);
|
||||
var deserializeMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// Populate (reuses existing object)
|
||||
|
|
@ -772,7 +772,7 @@ public class QuickBenchmark
|
|||
for (int i = 0; i < DefaultIterations; i++)
|
||||
{
|
||||
var target = CreatePopulateTarget(testOrder);
|
||||
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target);
|
||||
AcBinaryDeserializer.PopulateMerge(binaryData, target);
|
||||
}
|
||||
var mergeMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
|
|
@ -782,7 +782,7 @@ public class QuickBenchmark
|
|||
for (int i = 0; i < DefaultIterations; i++)
|
||||
{
|
||||
var target = CreatePopulateTarget(testOrder);
|
||||
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target, mergeWithRemoveOptions);
|
||||
AcBinaryDeserializer.PopulateMerge(binaryData, target, mergeWithRemoveOptions);
|
||||
}
|
||||
var mergeWithRemoveMs = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,3 @@ Comprehensive test suite for binary and JSON serialization: round-trips, referen
|
|||
- **`GeneratedSerializerIntegrationTests.cs`** — Verifies generated writer types implement IGeneratedBinaryWriter.
|
||||
- **`QuickBenchmark.cs`** — Performance comparison: AcBinary vs MessagePack.
|
||||
- **`AcSerializerTestHelper.cs`** — Factory methods for test data.
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,531 @@
|
|||
using System.Text;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip and correctness tests for <see cref="Utf8Transcoder"/>'s SIMD path tiers.
|
||||
///
|
||||
/// <para><b>Critical coverage</b>: each path tier (Vector512 / Vector256 / Vector128 / scalar) has
|
||||
/// minimum-size and boundary-crossing inputs to ensure the path is actually exercised. The
|
||||
/// Hungarian benchmark in <c>BenchmarkTestDataProvider</c> bails out of the AVX2 ASCII-prefix
|
||||
/// path early (first non-ASCII byte at position 4-5), so it cannot validate the long-ASCII path
|
||||
/// on its own. These tests fill that gap.</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class Utf8TranscoderTests
|
||||
{
|
||||
private static readonly Encoding Utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CountUtf8Chars — content classes
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[TestMethod]
|
||||
public void CountUtf8Chars_AsciiOnly_MatchesStringLength()
|
||||
{
|
||||
var s = "Hello, World! This is plain ASCII.";
|
||||
var bytes = Utf8.GetBytes(s);
|
||||
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CountUtf8Chars_HungarianMixed_MatchesStringLength()
|
||||
{
|
||||
var s = "árvíztűrő tükörfúrógép";
|
||||
var bytes = Utf8.GetBytes(s);
|
||||
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CountUtf8Chars_CjkBmp_MatchesStringLength()
|
||||
{
|
||||
var s = "你好世界 こんにちは 안녕하세요";
|
||||
var bytes = Utf8.GetBytes(s);
|
||||
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CountUtf8Chars_SupplementaryPlane_CountsSurrogatePairs()
|
||||
{
|
||||
// Each emoji is U+1F600-range (4-byte UTF-8 → 2-char surrogate pair in UTF-16)
|
||||
var s = "😀😁😂🎉"; // 4 codepoints, but 8 chars in UTF-16
|
||||
var bytes = Utf8.GetBytes(s);
|
||||
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
|
||||
Assert.AreEqual(8, s.Length, "Sanity check: each emoji is a surrogate pair");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CountUtf8Chars_MixedAllClasses_MatchesStringLength()
|
||||
{
|
||||
var s = "ASCII Magyar:árvíz CJK:你好 Emoji:😀";
|
||||
var bytes = Utf8.GetBytes(s);
|
||||
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CountUtf8Chars_Empty_ReturnsZero()
|
||||
{
|
||||
Assert.AreEqual(0, Utf8Transcoder.CountUtf8Chars(ReadOnlySpan<byte>.Empty));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// EncodeUtf8SinglePass + DecodeUtf8SinglePass — round-trip per content class
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_AsciiShort_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("Hello");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_AsciiExactly31Bytes_RoundTrip()
|
||||
{
|
||||
// Boundary: just below FixStr 31-byte limit, just below Vector256 threshold (32)
|
||||
AssertRoundTrip(new string('a', 31));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_AsciiExactly32Bytes_RoundTrip()
|
||||
{
|
||||
// Boundary: exactly Vector256<byte>.Count — Phase 1 AVX2 widen path triggers
|
||||
// CRITICAL: this validates the Vector256.Widen upper-half store offset bug-fix.
|
||||
AssertRoundTrip(new string('a', 32));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_AsciiLong_64Bytes_RoundTrip()
|
||||
{
|
||||
// Boundary: Vector512 threshold for the encoder; 2× Vector256 iter for the decoder
|
||||
AssertRoundTrip(new string('x', 64));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_AsciiVeryLong_500Bytes_RoundTrip()
|
||||
{
|
||||
// Multi-iter SIMD widen on the decoder; AVX-512 path on capable hosts
|
||||
AssertRoundTrip(new string('z', 500));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_HungarianShort_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("Termék");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_HungarianMedium_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("árvíztűrő tükörfúrógép");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_HungarianLong_RoundTrip()
|
||||
{
|
||||
// Long enough to span multiple Vector128/256 iterations
|
||||
AssertRoundTrip(string.Concat(Enumerable.Repeat("árvíztűrő tükörfúrógép ", 20)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_CjkBmp_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("你好世界 こんにちは 안녕하세요");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_CjkBmpLong_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip(string.Concat(Enumerable.Repeat("你好世界 ", 30)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_SupplementaryPlane_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("😀😁😂🎉🌟");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_MixedAllClasses_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("Plain ASCII + Magyar (árvíztűrő) + CJK (你好世界) + Emoji (😀🎉)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_LongMixed_RoundTrip()
|
||||
{
|
||||
// Long mixed content forcing all SIMD tiers + scalar tail to engage
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
sb.Append("ASCII run-").Append(i).Append(" Magyar:árvíz CJK:你好 ");
|
||||
}
|
||||
AssertRoundTrip(sb.ToString());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_BoundaryAsciiToHungarian_RoundTrip()
|
||||
{
|
||||
// ASCII prefix exactly at common boundaries, then non-ASCII switch
|
||||
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
|
||||
{
|
||||
var s = new string('a', asciiLen) + "árvíz";
|
||||
AssertRoundTrip(s, $"asciiLen={asciiLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_BoundaryAsciiToCjk_RoundTrip()
|
||||
{
|
||||
// 3-byte sequence boundary stress
|
||||
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
|
||||
{
|
||||
var s = new string('a', asciiLen) + "你好世界";
|
||||
AssertRoundTrip(s, $"asciiLen={asciiLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_BoundaryAsciiToEmoji_RoundTrip()
|
||||
{
|
||||
// 4-byte sequence boundary (surrogate pair in UTF-16)
|
||||
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
|
||||
{
|
||||
var s = new string('a', asciiLen) + "😀";
|
||||
AssertRoundTrip(s, $"asciiLen={asciiLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_Empty_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip(string.Empty);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_SingleAsciiChar_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("X");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_SingleHungarianChar_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("é");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_SingleCjkChar_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("好");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeDecode_SingleEmoji_RoundTrip()
|
||||
{
|
||||
AssertRoundTrip("😀");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GetUtf8ByteCount — content classes
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AsciiOnly_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("Hello, World! Plain ASCII text.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AsciiExactly7Bytes_MatchesBcl()
|
||||
{
|
||||
// Boundary: just below Vector128<ushort>.Count (8) — scalar tail only
|
||||
AssertGetUtf8ByteCountMatchesBcl(new string('a', 7));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AsciiExactly8Bytes_MatchesBcl()
|
||||
{
|
||||
// Boundary: exactly Vector128<ushort>.Count — Vector128 path triggers
|
||||
AssertGetUtf8ByteCountMatchesBcl(new string('a', 8));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AsciiExactly16Bytes_MatchesBcl()
|
||||
{
|
||||
// Boundary: exactly Vector256<ushort>.Count — Vector256 path triggers
|
||||
AssertGetUtf8ByteCountMatchesBcl(new string('a', 16));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AsciiExactly32Bytes_MatchesBcl()
|
||||
{
|
||||
// Boundary: exactly Vector512<ushort>.Count — Vector512 path triggers on AVX-512BW
|
||||
AssertGetUtf8ByteCountMatchesBcl(new string('a', 32));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AsciiVeryLong_500Chars_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl(new string('z', 500));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_HungarianShort_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("Termék");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_HungarianMedium_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("árvíztűrő tükörfúrógép");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_HungarianLong_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl(string.Concat(Enumerable.Repeat("árvíztűrő tükörfúrógép ", 20)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_CjkBmp_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("你好世界 こんにちは 안녕하세요");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_CjkBmpLong_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl(string.Concat(Enumerable.Repeat("你好世界 ", 30)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_SupplementaryPlane_MatchesBcl()
|
||||
{
|
||||
// Each emoji is 2 UTF-16 chars (surrogate pair) → 4 UTF-8 bytes total
|
||||
AssertGetUtf8ByteCountMatchesBcl("😀😁😂🎉🌟");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_MixedAllClasses_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("ASCII Magyar:árvíz CJK:你好 Emoji:😀");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_LongMixed_MatchesBcl()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
sb.Append("ASCII run-").Append(i).Append(" Magyar:árvíz CJK:你好 ");
|
||||
}
|
||||
AssertGetUtf8ByteCountMatchesBcl(sb.ToString());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_Empty_ReturnsZero()
|
||||
{
|
||||
Assert.AreEqual(0, Utf8Transcoder.GetUtf8ByteCount(ReadOnlySpan<char>.Empty));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_SingleAsciiChar_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("X");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_SingleHungarianChar_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("é");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_SingleCjkChar_MatchesBcl()
|
||||
{
|
||||
AssertGetUtf8ByteCountMatchesBcl("好");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_SingleEmoji_MatchesBcl()
|
||||
{
|
||||
// Single emoji = surrogate pair, exact 4 bytes
|
||||
AssertGetUtf8ByteCountMatchesBcl("😀");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_BoundaryAsciiToHungarian_MatchesBcl()
|
||||
{
|
||||
// Exercises split between SIMD ASCII region and 2-byte tail
|
||||
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
|
||||
{
|
||||
var s = new string('a', asciiLen) + "árvíz";
|
||||
var expected = Utf8.GetByteCount(s);
|
||||
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
|
||||
Assert.AreEqual(expected, actual, $"asciiLen={asciiLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_BoundaryAsciiToCjk_MatchesBcl()
|
||||
{
|
||||
// 3-byte sequence boundary stress
|
||||
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
|
||||
{
|
||||
var s = new string('a', asciiLen) + "你好世界";
|
||||
var expected = Utf8.GetByteCount(s);
|
||||
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
|
||||
Assert.AreEqual(expected, actual, $"asciiLen={asciiLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_BoundaryAsciiToEmoji_MatchesBcl()
|
||||
{
|
||||
// CRITICAL: tests that surrogate pairs split across SIMD chunks still produce correct count.
|
||||
// High surrogate may land in chunk N, low surrogate in chunk N+1; total must remain 4 bytes.
|
||||
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
|
||||
{
|
||||
var s = new string('a', asciiLen) + "😀";
|
||||
var expected = Utf8.GetByteCount(s);
|
||||
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
|
||||
Assert.AreEqual(expected, actual, $"asciiLen={asciiLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_MultipleEmojiBoundary_MatchesBcl()
|
||||
{
|
||||
// Surrogate pair split-stress: many emojis at varying offsets
|
||||
for (var prefixLen = 0; prefixLen <= 32; prefixLen++)
|
||||
{
|
||||
var s = new string('a', prefixLen) + "😀😁😂🎉🌟😀😁😂🎉🌟";
|
||||
var expected = Utf8.GetByteCount(s);
|
||||
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
|
||||
Assert.AreEqual(expected, actual, $"prefixLen={prefixLen}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetUtf8ByteCount_AgreesWithEncodeUtf8SinglePass_AllContentClasses()
|
||||
{
|
||||
// Round-trip contract: the byte count returned must equal the bytesWritten by EncodeUtf8SinglePass.
|
||||
// This is the load-bearing invariant for two-pass [VarUInt][bytes] writes in cold-fallback paths.
|
||||
var samples = new[]
|
||||
{
|
||||
"Hello",
|
||||
"árvíztűrő tükörfúrógép",
|
||||
"你好世界",
|
||||
"😀🎉🌟",
|
||||
"ASCII Magyar:árvíz CJK:你好 Emoji:😀",
|
||||
new string('z', 500),
|
||||
string.Concat(Enumerable.Repeat("árvíztűrő tükörfúrógép ", 20))
|
||||
};
|
||||
|
||||
foreach (var s in samples)
|
||||
{
|
||||
var byteCountFromCounter = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
|
||||
var dst = new byte[s.Length * 4];
|
||||
var bytesWritten = Utf8Transcoder.EncodeUtf8SinglePass(s.AsSpan(), dst.AsSpan());
|
||||
Assert.AreEqual(bytesWritten, byteCountFromCounter,
|
||||
$"GetUtf8ByteCount disagrees with EncodeUtf8SinglePass for [{s.Substring(0, Math.Min(20, s.Length))}...]");
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Decoder-side cross-check: BCL Encoding.UTF8.GetString reference
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeUtf8SinglePass_MatchesBclGetString_Ascii()
|
||||
{
|
||||
AssertDecodeMatchesBcl("ASCII test string with spaces and digits 0123456789.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeUtf8SinglePass_MatchesBclGetString_LongAscii32Plus()
|
||||
{
|
||||
// CRITICAL — exercises the Vector256 ASCII prefix widen path that had the offset bug
|
||||
AssertDecodeMatchesBcl(new string('A', 32));
|
||||
AssertDecodeMatchesBcl(new string('A', 33));
|
||||
AssertDecodeMatchesBcl(new string('A', 64));
|
||||
AssertDecodeMatchesBcl(new string('A', 65));
|
||||
AssertDecodeMatchesBcl(new string('B', 100));
|
||||
AssertDecodeMatchesBcl(new string('C', 256));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeUtf8SinglePass_MatchesBclGetString_Hungarian()
|
||||
{
|
||||
AssertDecodeMatchesBcl("árvíztűrő tükörfúrógép");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeUtf8SinglePass_MatchesBclGetString_Mixed()
|
||||
{
|
||||
AssertDecodeMatchesBcl("Plain ASCII + Magyar (árvíz) + CJK (你好) + Emoji (😀)");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that EncodeUtf8SinglePass produces bytes identical to <see cref="Encoding.UTF8.GetBytes"/>,
|
||||
/// and that DecodeUtf8SinglePass on those bytes reconstructs the original string exactly.
|
||||
/// </summary>
|
||||
private static void AssertRoundTrip(string original, string? context = null)
|
||||
{
|
||||
var ctx = context is null ? string.Empty : $" [{context}]";
|
||||
|
||||
// 1. Encoder produces bytes identical to BCL Encoding.UTF8
|
||||
var dst = new byte[original.Length * 4]; // worst-case UTF-8
|
||||
var bytesWritten = Utf8Transcoder.EncodeUtf8SinglePass(original.AsSpan(), dst.AsSpan());
|
||||
var encoded = dst.AsSpan(0, bytesWritten).ToArray();
|
||||
var bclEncoded = Utf8.GetBytes(original);
|
||||
CollectionAssert.AreEqual(bclEncoded, encoded, $"Encoder output mismatch{ctx}");
|
||||
|
||||
// 2. CountUtf8Chars matches the original char count
|
||||
var charCount = Utf8Transcoder.CountUtf8Chars(encoded);
|
||||
Assert.AreEqual(original.Length, charCount, $"Char count mismatch{ctx}");
|
||||
|
||||
// 3. DecodeUtf8SinglePass reconstructs the original string exactly
|
||||
var decoded = string.Create(charCount, encoded, static (chars, bytes) =>
|
||||
{
|
||||
Utf8Transcoder.DecodeUtf8SinglePass(bytes, chars);
|
||||
});
|
||||
Assert.AreEqual(original, decoded, $"Decoder output mismatch{ctx}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="Utf8Transcoder.GetUtf8ByteCount"/> matches
|
||||
/// <see cref="Encoding.GetByteCount(string)"/> for the same input. This is the BCL parity
|
||||
/// invariant — any divergence means the SIMD byte counter is producing wrong values that
|
||||
/// would corrupt VarUInt length prefixes in <c>WriteStringUtf8Internal</c>.
|
||||
/// </summary>
|
||||
private static void AssertGetUtf8ByteCountMatchesBcl(string original)
|
||||
{
|
||||
var expected = Utf8.GetByteCount(original);
|
||||
var actual = Utf8Transcoder.GetUtf8ByteCount(original.AsSpan());
|
||||
Assert.AreEqual(expected, actual, $"GetUtf8ByteCount mismatch for input length {original.Length}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that DecodeUtf8SinglePass produces output identical to <see cref="Encoding.UTF8.GetString"/>
|
||||
/// for the same byte input. Catches silent decoder bugs that pass the round-trip test
|
||||
/// (e.g. write-overlap that happens to land back on the right value by accident).
|
||||
/// </summary>
|
||||
private static void AssertDecodeMatchesBcl(string original)
|
||||
{
|
||||
var bytes = Utf8.GetBytes(original);
|
||||
var bclDecoded = Utf8.GetString(bytes);
|
||||
var charCount = Utf8Transcoder.CountUtf8Chars(bytes);
|
||||
var ourDecoded = string.Create(charCount, bytes, static (chars, b) =>
|
||||
{
|
||||
Utf8Transcoder.DecodeUtf8SinglePass(b, chars);
|
||||
});
|
||||
Assert.AreEqual(bclDecoded, ourDecoded, $"Decoder mismatch for input length {bytes.Length}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,548 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Charset suffix presets for the per-property string augmentation in
|
||||
/// <c>BenchmarkStringSupport.ToLongString</c>. The benchmark applies the configured suffix to every
|
||||
/// short (≤ <c>FixStrMaxLength</c>) string property across the test data graph (via reflection in
|
||||
/// <c>BenchmarkStringSupport.EnsureAllStringsBypassFixStr</c>), producing long-string benchmark payloads
|
||||
/// with a controlled UTF-8 content profile.
|
||||
///
|
||||
/// Switch by assigning to <see cref="BenchmarkTestDataProvider.LongStringSuffix"/> from the interactive
|
||||
/// Settings → Charset submenu (or programmatically). The active charset is recorded in the .LLM
|
||||
/// markdown output header so per-charset bench files are self-documenting.
|
||||
/// </summary>
|
||||
public static class CharsetSuffixes
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Consistent length across all charsets (UTF-16 char count, NOT UTF-8 byte count):
|
||||
// *Short = 40 char (5-char base × 8 repetitions) → StringSmall / StringAscii tier
|
||||
// *Long = 280 char (Short × 7) → StringMedium / StringAscii tier
|
||||
//
|
||||
// Same length across charsets isolates the workload variable to UTF-8 byte content
|
||||
// (1-byte ASCII vs 2-byte Latin1 / Cyrillic vs 3-byte CJK vs mixed) — wire-size and
|
||||
// encode/decode cost differences are pure charset effects, not length effects.
|
||||
//
|
||||
// Const-concat for compile-time evaluation (usable as attribute / DataRow source).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Empty suffix — baseline string property values stay short, hitting the
|
||||
/// <c>FixStrAscii</c> / short-string fast-path. Stress-test for short-string code paths.</summary>
|
||||
public const string AsciiFix = "";
|
||||
|
||||
// ── Pure ASCII (every byte < 0x80) ──
|
||||
// Tier: StringAscii (167) — byte→char SIMD widening, zero UTF-8 decode.
|
||||
// UTF-8 byte count: 40 byte (Short), 280 byte (Long) — 1:1 char:byte.
|
||||
private const string AsciiBase = " quic"; // 5 char ASCII
|
||||
public const string AsciiShort = AsciiBase + AsciiBase + AsciiBase + AsciiBase
|
||||
+ AsciiBase + AsciiBase + AsciiBase + AsciiBase; // 40 char
|
||||
public const string AsciiLong = AsciiShort + AsciiShort + AsciiShort + AsciiShort
|
||||
+ AsciiShort + AsciiShort + AsciiShort; // 280 char
|
||||
|
||||
// ── Latin1 (Hungarian proxy — ISO-8859-1 + Latin-2 ő/ű) ──
|
||||
// Tier: StringSmall (91) Short / StringMedium (94) Long.
|
||||
// UTF-8 byte count: ~72 byte Short (5 char base = 9 byte UTF-8: space+á+r+v+í), ~504 byte Long.
|
||||
private const string Latin1Base = " árví"; // 5 char (space + á + r + v + í) — multi-byte mix
|
||||
public const string Latin1Fix = Latin1Base; // 5 char (FixStr-lean profile)
|
||||
public const string Latin1Short = Latin1Base + Latin1Base + Latin1Base + Latin1Base
|
||||
+ Latin1Base + Latin1Base + Latin1Base + Latin1Base; // 40 char
|
||||
public const string Latin1Long = Latin1Short + Latin1Short + Latin1Short + Latin1Short
|
||||
+ Latin1Short + Latin1Short + Latin1Short; // 280 char
|
||||
|
||||
// ── CJK BMP (Chinese / Japanese / Korean Basic Multilingual Plane) ──
|
||||
// Tier: StringSmall (91) Short / StringMedium (94) Long.
|
||||
// UTF-8 byte count: ~104 byte Short (5 char base = 13 byte UTF-8: 1 ASCII space + 4×3-byte CJK),
|
||||
// ~728 byte Long. Homogeneous 3-byte runs — primary win region for SIMD multi-byte transcoder.
|
||||
private const string CjkBmpBase = " 你好世界"; // 5 char (space + 4 Chinese)
|
||||
public const string CjkBmpShort = CjkBmpBase + CjkBmpBase + CjkBmpBase + CjkBmpBase
|
||||
+ CjkBmpBase + CjkBmpBase + CjkBmpBase + CjkBmpBase; // 40 char
|
||||
public const string CjkBmpLong = CjkBmpShort + CjkBmpShort + CjkBmpShort + CjkBmpShort
|
||||
+ CjkBmpShort + CjkBmpShort + CjkBmpShort; // 280 char
|
||||
|
||||
// ── Cyrillic (Russian / Ukrainian) ──
|
||||
// Tier: StringSmall (91) Short / StringMedium (94) Long.
|
||||
// UTF-8 byte count: ~72 byte Short (5 char base = 9 byte UTF-8: 1 ASCII + 4×2-byte Cyrillic),
|
||||
// ~504 byte Long. Homogeneous 2-byte runs — different shape than Latin1 interspersed.
|
||||
private const string CyrillicBase = " Прив"; // 5 char (space + 4 Cyrillic)
|
||||
public const string CyrillicShort = CyrillicBase + CyrillicBase + CyrillicBase + CyrillicBase
|
||||
+ CyrillicBase + CyrillicBase + CyrillicBase + CyrillicBase; // 40 char
|
||||
public const string CyrillicLong = CyrillicShort + CyrillicShort + CyrillicShort + CyrillicShort
|
||||
+ CyrillicShort + CyrillicShort + CyrillicShort; // 280 char
|
||||
|
||||
// ── Mixed (multi-codepage in one payload) ──
|
||||
// Tier: StringSmall (91) Short / StringMedium (94) Long.
|
||||
// UTF-8 byte count: ~88 byte Short (5 char base = 11 byte UTF-8: 1 ASCII + 1×2-byte Hungarian
|
||||
// + 1×3-byte CJK + 2×2-byte Cyrillic), ~616 byte Long. No surrogate pairs (keeps UTF-16
|
||||
// length predictable); cross-tier transcoder coverage in one payload.
|
||||
private const string MixedBase = " á你Пй"; // 5 char (space + Hungarian + Chinese + 2× Cyrillic)
|
||||
public const string MixedShort = MixedBase + MixedBase + MixedBase + MixedBase
|
||||
+ MixedBase + MixedBase + MixedBase + MixedBase; // 40 char
|
||||
public const string MixedLong = MixedShort + MixedShort + MixedShort + MixedShort
|
||||
+ MixedShort + MixedShort + MixedShort; // 280 char
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Cross-family shared state. The charset suffix is a global benchmark configuration — settable
|
||||
// once via the interactive Menu, applied uniformly to every family's data construction. Lives in
|
||||
// a non-generic helper so it ISN'T per-closed-generic (which would cause the Menu setter to affect
|
||||
// only one family). The <see cref="BenchmarkTestDataProvider.LongStringSuffix"/> forwarding
|
||||
// property preserves the existing Menu.cs API surface.
|
||||
// ============================================================================================
|
||||
|
||||
internal static class BenchmarkStringSupport
|
||||
{
|
||||
internal const int FixStrMaxLength = 31;
|
||||
|
||||
internal static string LongStringSuffix = CharsetSuffixes.Latin1Long;
|
||||
|
||||
private sealed class ReferenceComparer : IEqualityComparer<object>
|
||||
{
|
||||
public static readonly ReferenceComparer Instance = new();
|
||||
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
|
||||
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
|
||||
internal static void EnsureAllStringsBypassFixStr(object? root)
|
||||
{
|
||||
if (root == null) return;
|
||||
|
||||
var visited = new HashSet<object>(ReferenceComparer.Instance);
|
||||
var stack = new Stack<object>();
|
||||
stack.Push(root);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var current = stack.Pop();
|
||||
if (!visited.Add(current)) continue;
|
||||
|
||||
if (current is IEnumerable enumerable && current is not string)
|
||||
{
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (item != null)
|
||||
stack.Push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var type = current.GetType();
|
||||
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
if (!property.CanRead) continue;
|
||||
|
||||
if (property.PropertyType == typeof(string))
|
||||
{
|
||||
if (!property.CanWrite) continue;
|
||||
|
||||
var value = (string?)property.GetValue(current);
|
||||
property.SetValue(current, ToLongString(value));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.PropertyType.IsValueType || property.PropertyType.IsEnum)
|
||||
continue;
|
||||
|
||||
var child = property.GetValue(current);
|
||||
if (child != null)
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string ToLongString(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return "Benchmark_String_Value" + LongStringSuffix;
|
||||
|
||||
if (value.Length > FixStrMaxLength)
|
||||
return value;
|
||||
|
||||
return value + LongStringSuffix;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Generic test-data provider. One closing-generic alias per family — see
|
||||
// <see cref="BenchmarkTestDataProvider"/> (the <c>_All_True</c> family, MSTEST-compatible name) and
|
||||
// <see cref="BenchmarkTestDataProvider_All_False"/> (the <c>_All_False</c> family, Phase 1 benchmark
|
||||
// target). The five cell-creator methods + ClearDeepLevelRefs are written once on the generic base,
|
||||
// using the constrained <c>TestDataFactory<TOrder, ...></c> for per-family element creation.
|
||||
// ============================================================================================
|
||||
|
||||
public abstract class BenchmarkTestDataProvider<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>
|
||||
where TOrder : TestOrderBase<TItem, TTag, TUser, TCategory, TMetadata, TPreferences>, new()
|
||||
where TItem : TestOrderItemBase<TPallet, TTag, TUser, TMetadata, TOrder, TPreferences>, new()
|
||||
where TPallet : TestPalletBase<TMeasurement, TTag, TUser, TCategory, TMetadata, TItem, TPreferences>, new()
|
||||
where TMeasurement : TestMeasurementBase<TPoint, TTag, TUser, TPallet, TPreferences>, new()
|
||||
where TPoint : TestMeasurementPointBase<TTag, TUser, TMeasurement, TPreferences>, new()
|
||||
where TTag : SharedTagBase, new()
|
||||
where TUser : SharedUserBase<TPreferences>, new()
|
||||
where TCategory : SharedCategoryBase, new()
|
||||
where TMetadata : MetadataInfoBase<TMetadata>, new()
|
||||
where TPreferences : UserPreferencesBase, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// Active long-string suffix appended to short string properties during benchmark data construction.
|
||||
/// Forwards to <see cref="BenchmarkStringSupport.LongStringSuffix"/> (a non-generic shared field) so
|
||||
/// the setter is family-agnostic — both <c>BenchmarkTestDataProvider.LongStringSuffix = …</c> and
|
||||
/// <c>BenchmarkTestDataProvider_All_False.LongStringSuffix = …</c> route to the same backing value.
|
||||
/// Without this forwarding, a per-closed-generic static field on the base would store the suffix
|
||||
/// independently per family — the Menu setter would only affect whichever alias it addressed.
|
||||
/// </summary>
|
||||
public static string LongStringSuffix
|
||||
{
|
||||
get => BenchmarkStringSupport.LongStringSuffix;
|
||||
set => BenchmarkStringSupport.LongStringSuffix = value;
|
||||
}
|
||||
|
||||
// Shortcut alias for the matching factory closing-generic. Saves typing the 10-param cluster
|
||||
// on every Create* call inside this class.
|
||||
private static class Factory
|
||||
{
|
||||
public static void ResetIdCounter() =>
|
||||
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.ResetIdCounter();
|
||||
public static TTag CreateTag(string? name = null) =>
|
||||
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateTag(name);
|
||||
public static TUser CreateUser(string? username = null) =>
|
||||
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateUser(username);
|
||||
public static TCategory CreateCategory(string? name = null) =>
|
||||
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateCategory(name);
|
||||
public static TMetadata CreateMetadata(string? key = null, bool withChild = false) =>
|
||||
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateMetadata(key, withChild);
|
||||
public static TOrder CreateOrder(
|
||||
int itemCount, int palletsPerItem, int measurementsPerPallet, int pointsPerMeasurement,
|
||||
TTag? sharedTag = null, TUser? sharedUser = null, TMetadata? sharedMetadata = null,
|
||||
TPreferences? sharedPreferences = null, TCategory? sharedCategory = null) =>
|
||||
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateOrder(
|
||||
itemCount, palletsPerItem, measurementsPerPallet, pointsPerMeasurement,
|
||||
sharedTag, sharedUser, sharedMetadata, sharedPreferences, sharedCategory);
|
||||
}
|
||||
|
||||
public static List<TestDataSet> CreateTestDataSets(bool resetId = true)
|
||||
{
|
||||
return new List<TestDataSet>
|
||||
{
|
||||
CreateSmallTestData(resetId),
|
||||
CreateMediumTestData(resetId),
|
||||
CreateLargeTestData(resetId),
|
||||
CreateRepeatedStringsTestData(resetId),
|
||||
CreateDeepNestedTestData(resetId)
|
||||
};
|
||||
}
|
||||
|
||||
private static TestDataSet<TOrder> CreateSmallTestData(bool resetId = true)
|
||||
{
|
||||
if (resetId) Factory.ResetIdCounter();
|
||||
|
||||
var sharedTag = Factory.CreateTag("SharedTag");
|
||||
var sharedUser = Factory.CreateUser("shareduser");
|
||||
|
||||
var order = Factory.CreateOrder(
|
||||
itemCount: 2,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 2,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser);
|
||||
|
||||
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet<TOrder>("Small (2x2x2x2)", order, iidRefPercent: 20);
|
||||
}
|
||||
|
||||
private static TestDataSet<TOrder> CreateMediumTestData(bool resetId = true)
|
||||
{
|
||||
if (resetId) Factory.ResetIdCounter();
|
||||
|
||||
var sharedTag = Factory.CreateTag("SharedTag");
|
||||
var sharedUser = Factory.CreateUser("shareduser");
|
||||
var sharedMeta = Factory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
var sharedPreferences = new TPreferences
|
||||
{
|
||||
Theme = "dark",
|
||||
Language = "hungarian",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "weekly"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = Factory.CreateOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 4,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedMetadata: sharedMeta,
|
||||
sharedPreferences: sharedPreferences);
|
||||
|
||||
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet<TOrder>("Medium (3x3x3x4)", order, iidRefPercent: 20);
|
||||
}
|
||||
|
||||
private static TestDataSet<TOrder> CreateLargeTestData(bool resetId = true)
|
||||
{
|
||||
if (resetId) Factory.ResetIdCounter();
|
||||
|
||||
var sharedTag = Factory.CreateTag("SharedTag");
|
||||
var sharedUser = Factory.CreateUser("shareduser");
|
||||
|
||||
var sharedPreferences = new TPreferences
|
||||
{
|
||||
Theme = "light",
|
||||
Language = "german",
|
||||
NotificationsEnabled = false,
|
||||
EmailDigestFrequency = "daily"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = Factory.CreateOrder(
|
||||
itemCount: 5,
|
||||
palletsPerItem: 5,
|
||||
measurementsPerPallet: 5,
|
||||
pointsPerMeasurement: 10,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedPreferences: sharedPreferences);
|
||||
|
||||
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet<TOrder>("Large (5x5x5x10)", order, iidRefPercent: 20);
|
||||
}
|
||||
|
||||
private static TestDataSet<TOrder> CreateRepeatedStringsTestData(bool resetId = true)
|
||||
{
|
||||
if (resetId) Factory.ResetIdCounter();
|
||||
|
||||
var sharedTag = Factory.CreateTag("RepeatedTag");
|
||||
var sharedUser = Factory.CreateUser("repeateduser");
|
||||
|
||||
var sharedPreferences = new TPreferences
|
||||
{
|
||||
Theme = "dark",
|
||||
Language = "hungarian",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "weekly"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = Factory.CreateOrder(
|
||||
itemCount: 10,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 2,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedPreferences: sharedPreferences);
|
||||
|
||||
// Repeated string fields — ProductName on items + PalletCode on pallets. Both are common
|
||||
// across the hierarchy, exercising string-interning deduplication on the Default preset
|
||||
// (which has UseStringInterning = All). Targeting ~20% repeated-string share overall.
|
||||
// Baselines are short ASCII (≤ FixStrMaxLength) so EnsureAllStringsBypassFixStr appends the
|
||||
// active CharsetSuffix — the resulting payload's UTF-8 content profile is governed entirely
|
||||
// by the selected charset (not contaminated by hard-coded Hungarian baseline values).
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
item.Status = TestStatus.Processing;
|
||||
item.ProductName = "ProductName";
|
||||
|
||||
foreach (var pallet in item.Pallets)
|
||||
{
|
||||
pallet.PalletCode = "PalletCode";
|
||||
}
|
||||
}
|
||||
|
||||
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet<TOrder>("Repeated Strings (10 items)", order, iidRefPercent: 20);
|
||||
}
|
||||
|
||||
private static TestDataSet<TOrder> CreateDeepNestedTestData(bool resetId = true)
|
||||
{
|
||||
if (resetId) Factory.ResetIdCounter();
|
||||
|
||||
var sharedTag = Factory.CreateTag("DeepTag");
|
||||
var sharedUser = Factory.CreateUser("deepuser");
|
||||
var sharedCategory = Factory.CreateCategory("DeepCategory");
|
||||
|
||||
var sharedPreferences = new TPreferences
|
||||
{
|
||||
Theme = "light",
|
||||
Language = "french",
|
||||
NotificationsEnabled = false,
|
||||
EmailDigestFrequency = "monthly"
|
||||
};
|
||||
sharedUser.Preferences = sharedPreferences;
|
||||
|
||||
var order = Factory.CreateOrder(
|
||||
itemCount: 2,
|
||||
palletsPerItem: 4,
|
||||
measurementsPerPallet: 4,
|
||||
pointsPerMeasurement: 8,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedPreferences: sharedPreferences,
|
||||
sharedCategory: sharedCategory);
|
||||
|
||||
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
|
||||
|
||||
ClearDeepLevelRefs(order);
|
||||
|
||||
return new TestDataSet<TOrder>("Deep Nested (2x4x4x8)", order, iidRefPercent: 20);
|
||||
}
|
||||
|
||||
private static void ClearDeepLevelRefs(TOrder order)
|
||||
{
|
||||
// Keep shared IId refs at the pallet level (Tag + Inspector) — these contribute the bulk of
|
||||
// the ~20% IId-ref share that the test data targets. Only Category is cleared at this level
|
||||
// (one-of-three clears keep the share moderate). The deeper measurement / point levels are
|
||||
// cleared entirely so deep-tree ref noise does not skew the share upward beyond ~20%.
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
foreach (var pallet in item.Pallets)
|
||||
{
|
||||
// pallet.Tag = null; // KEEP for ~20% IId-ref share (was cleared)
|
||||
// pallet.Inspector = null; // KEEP for ~20% IId-ref share (was cleared)
|
||||
pallet.Category = null;
|
||||
|
||||
foreach (var measurement in pallet.Measurements)
|
||||
{
|
||||
measurement.Tag = null;
|
||||
measurement.Operator = null;
|
||||
|
||||
foreach (var point in measurement.Points)
|
||||
{
|
||||
point.Tag = null;
|
||||
point.Verifier = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Closing-generic aliases for the provider. Same pattern as the factory: a bare-name class for
|
||||
// MSTEST backward compatibility (kept on _All_True), and a _All_False suffix variant for the
|
||||
// Phase 1 benchmark target. The static <c>LongStringSuffix</c> forwarding property lives on the
|
||||
// generic base above — accessible identically through either alias (<c>BenchmarkTestDataProvider.LongStringSuffix</c>
|
||||
// or <c>BenchmarkTestDataProvider_All_False.LongStringSuffix</c>), both routing to the same
|
||||
// <see cref="BenchmarkStringSupport.LongStringSuffix"/> shared field. Symmetric API surface across
|
||||
// families — no per-alias asymmetry.
|
||||
// ============================================================================================
|
||||
|
||||
/// <summary>
|
||||
/// <c>_All_True</c> family provider — preserves the bare-name API surface
|
||||
/// (<c>BenchmarkTestDataProvider.CreateTestDataSets()</c>) that the SGen-vs-runtime compatibility
|
||||
/// test depends on. <c>LongStringSuffix</c> is inherited from the generic base.
|
||||
/// </summary>
|
||||
public sealed class BenchmarkTestDataProvider : BenchmarkTestDataProvider<
|
||||
TestOrder_All_True, TestOrderItem_All_True, TestPallet_All_True, TestMeasurement_All_True, TestMeasurementPoint_All_True,
|
||||
SharedTag_All_True, SharedUser_All_True, SharedCategory_All_True, MetadataInfo_All_True, UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>_All_False</c> family provider — Phase 1 benchmark target. Inherits the generic cell-creator
|
||||
/// methods unchanged; the closed-generic <c>new TOrder()</c> calls inside the cell methods construct
|
||||
/// <c>TestOrder_All_False</c> graphs.
|
||||
/// </summary>
|
||||
public sealed class BenchmarkTestDataProvider_All_False : BenchmarkTestDataProvider<
|
||||
TestOrder_All_False, TestOrderItem_All_False, TestPallet_All_False, TestMeasurement_All_False, TestMeasurementPoint_All_False,
|
||||
SharedTag_All_False, SharedUser_All_False, SharedCategory_All_False, MetadataInfo_All_False, UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// TestDataSet — abstract metadata base + generic-ordered concrete. Orchestration code iterates
|
||||
// over the base type (Name/DisplayName/TypeName/IIdRefPercent only); concrete consumers
|
||||
// (CreateSerializers, Output binary-output dump) downcast to TestDataSet<TOrder> to access the
|
||||
// typed Order.
|
||||
// ============================================================================================
|
||||
|
||||
public abstract class TestDataSet
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Percentage of IId shared references in the data (0-100).
|
||||
/// Higher values mean more deduplication benefit for Default mode.
|
||||
/// </summary>
|
||||
public int IIdRefPercent { get; }
|
||||
|
||||
// Type-keyed variant registry. Phase 2 multi-variant dispatch: AcBinary's options preset
|
||||
// decides which variant graph it serializes (FastMode → _All_False, Default → _All_True),
|
||||
// while MemPack/MsgPack canonically use one (typically _All_True). The cells build all
|
||||
// known variants upfront and register them here so CreateSerializers can hand each benchmark
|
||||
// its matching graph instance.
|
||||
private readonly Dictionary<Type, object> _variants = new();
|
||||
|
||||
protected TestDataSet(string name, int iidRefPercent)
|
||||
{
|
||||
Name = name;
|
||||
IIdRefPercent = iidRefPercent;
|
||||
}
|
||||
|
||||
public abstract string TypeName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets display name including IId ref percentage if set.
|
||||
/// </summary>
|
||||
public string DisplayName => IIdRefPercent > 0
|
||||
? $"{Name} [{IIdRefPercent}% IId refs]"
|
||||
: Name;
|
||||
|
||||
/// <summary>
|
||||
/// Register a variant graph for this cell. Called by builders. Idempotent on the same type
|
||||
/// (last-write-wins, no error) so an alias's primary registration is harmless even if
|
||||
/// cross-registration adds the same variant later.
|
||||
/// </summary>
|
||||
public void RegisterVariant<T>(T variant) where T : class => _variants[typeof(T)] = variant;
|
||||
|
||||
/// <summary>
|
||||
/// Get a registered variant by type. Throws <see cref="InvalidOperationException"/> if not
|
||||
/// registered — fail-fast surfaces a mismatch between the variant a benchmark expects and
|
||||
/// what the cell-builder populated.
|
||||
/// </summary>
|
||||
public T GetOrder<T>() where T : class
|
||||
{
|
||||
if (_variants.TryGetValue(typeof(T), out var v)) return (T)v;
|
||||
throw new InvalidOperationException($"Variant '{typeof(T).Name}' not registered for cell '{Name}' (registered: {string.Join(", ", _variants.Keys.Select(k => k.Name))})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a variant is registered. Use to gate optional benchmarks that may not have
|
||||
/// their variant prepared in every cell.
|
||||
/// </summary>
|
||||
public bool HasOrder<T>() where T : class => _variants.ContainsKey(typeof(T));
|
||||
}
|
||||
|
||||
public sealed class TestDataSet<TOrder> : TestDataSet
|
||||
where TOrder : class
|
||||
{
|
||||
public TOrder Order { get; }
|
||||
|
||||
public TestDataSet(string name, TOrder order, int iidRefPercent = 0)
|
||||
: base(name, iidRefPercent)
|
||||
{
|
||||
Order = order;
|
||||
RegisterVariant(order); // primary registers itself
|
||||
}
|
||||
|
||||
public override string TypeName => Order.GetType().Name;
|
||||
}
|
||||
|
|
@ -11,7 +11,3 @@ Shared test entities, enums, data factories, and SignalR test infrastructure. Us
|
|||
- **`TestDataFactory.cs`** — Centralized factory with ID sequencing: CreateTag(), CreateCategory(), CreateUser(), CreateOrder(), CreateOrderItem().
|
||||
- **`SignalRTestInfrastructure.cs`** — SignalRMessageFactory, DTOs, CommonSignalRTags, SignalRBenchmarkData.
|
||||
- **`TestLogger.cs`** — Logger with capture for assertions: HasErrorLogs, HasWarningLogs, GetErrorMessages().
|
||||
|
||||
---
|
||||
|
||||
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
using AyCode.Core.Serializers.Attributes;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Intentionally NOT marked with [AcBinarySerializable].
|
||||
/// Used to reproduce the generated-writer path where the parent has a complex reference property
|
||||
/// without a generated writer on the child type.
|
||||
/// </summary>
|
||||
public class NonGeneratedComplexCustomer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression model for SGen complex-property null handling.
|
||||
/// The Customer property is non-nullable in signature, but runtime data can still contain null.
|
||||
/// Serializer must emit PropertySkip instead of dereferencing null.
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
public class SGenNullComplexParent
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public NonGeneratedComplexCustomer Customer { get; set; } = null!;
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression model for SGen Collection-property null handling — the fallback WriteValueGenerated
|
||||
/// branch in GenWriter.cs PropertyTypeKind.Collection case (~line 321), when the element type has
|
||||
/// no generated writer (cross-assembly / unattributed). The Items property is non-nullable in
|
||||
/// signature, but runtime data can still contain null. Serializer must emit PropertySkip instead
|
||||
/// of forwarding null into the WriteValueGenerated bridge.
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
public class SGenNullCollectionParent
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public List<NonGeneratedComplexCustomer> Items { get; set; } = null!;
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression model for SGen Dictionary-property null handling — the EmitDirectDictionaryWrite
|
||||
/// branch in GenWriter.cs (~line 1031). The branch was already null-safe at the time of the
|
||||
/// N4P8 audit (explicit `if (a == null) PropertySkip` at line ~1037), but no regression test
|
||||
/// existed to pin the behaviour. The Mapping property is non-nullable in signature, but runtime
|
||||
/// data can still contain null — same pattern as <see cref="SGenNullComplexParent"/> /
|
||||
/// <see cref="SGenNullCollectionParent"/>.
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
public class SGenNullDictionaryParent
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public Dictionary<string, NonGeneratedComplexCustomer> Mapping { get; set; } = null!;
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Interfaces;
|
||||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using MemoryPack;
|
||||
using MessagePack;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
#region Shared Reference Base Types
|
||||
|
||||
public abstract class SharedTagBase : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
[AcStringIntern(true)]
|
||||
[Key(2)]
|
||||
public string Color { get; set; } = "#000000";
|
||||
[Key(3)]
|
||||
public int Priority { get; set; }
|
||||
[Key(4)]
|
||||
public bool IsActive { get; set; } = true;
|
||||
[Key(5)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(6)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public abstract class SharedCategoryBase : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
[Key(2)]
|
||||
public string? Description { get; set; }
|
||||
[Key(3)]
|
||||
public int SortOrder { get; set; }
|
||||
[Key(4)]
|
||||
public bool IsDefault { get; set; }
|
||||
[Key(5)]
|
||||
public int? ParentCategoryId { get; set; }
|
||||
[Key(6)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(7)]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public abstract class SharedUserBase<TPreferences> : IId<int>
|
||||
where TPreferences : UserPreferencesBase
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Username { get; set; } = "";
|
||||
[Key(2)]
|
||||
public string Email { get; set; } = "";
|
||||
[Key(3)]
|
||||
public string FirstName { get; set; } = "";
|
||||
[Key(4)]
|
||||
public string LastName { get; set; } = "";
|
||||
[Key(5)]
|
||||
public bool IsActive { get; set; } = true;
|
||||
[Key(6)]
|
||||
public TestUserRole Role { get; set; } = TestUserRole.User;
|
||||
[Key(7)]
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
[Key(8)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(9)]
|
||||
public TPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
public abstract class UserPreferencesBase
|
||||
{
|
||||
[AcStringIntern(true)]
|
||||
[Key(0)]
|
||||
public string Theme { get; set; } = "light";
|
||||
[AcStringIntern(true)]
|
||||
[Key(1)]
|
||||
public string Language { get; set; } = "en-US";
|
||||
[Key(2)]
|
||||
public bool NotificationsEnabled { get; set; } = true;
|
||||
[AcStringIntern(true)]
|
||||
[Key(3)]
|
||||
public string? EmailDigestFrequency { get; set; }
|
||||
}
|
||||
|
||||
public abstract class MetadataInfoBase<TSelf>
|
||||
where TSelf : MetadataInfoBase<TSelf>
|
||||
{
|
||||
[AcStringIntern(true)]
|
||||
[Key(0)]
|
||||
public string Key { get; set; } = "";
|
||||
[AcStringIntern(true)]
|
||||
[Key(1)]
|
||||
public string Value { get; set; } = "";
|
||||
[Key(2)]
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Key(3)]
|
||||
public TSelf? ChildMetadata { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Order Hierarchy Base Types
|
||||
|
||||
public abstract class TestOrderBase<TItem, TTag, TUser, TCategory, TMetadata, TPreferences> : IId<int>
|
||||
where TItem : class
|
||||
where TTag : SharedTagBase
|
||||
where TUser : SharedUserBase<TPreferences>
|
||||
where TCategory : SharedCategoryBase
|
||||
where TMetadata : MetadataInfoBase<TMetadata>
|
||||
where TPreferences : UserPreferencesBase
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string OrderNumber { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
|
||||
[Key(3)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Key(4)]
|
||||
public DateTime? PaidDateUtc { get; set; }
|
||||
|
||||
[Key(5)]
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
public List<TItem> Items { get; set; } = [];
|
||||
|
||||
[Key(7)]
|
||||
public TTag? PrimaryTag { get; set; }
|
||||
|
||||
[Key(8)]
|
||||
public TTag? SecondaryTag { get; set; }
|
||||
|
||||
[Key(9)]
|
||||
public TUser? Owner { get; set; }
|
||||
|
||||
[Key(10)]
|
||||
public TCategory? Category { get; set; }
|
||||
|
||||
[Key(11)]
|
||||
public List<TTag> Tags { get; set; } = [];
|
||||
|
||||
[Key(12)]
|
||||
public TMetadata? OrderMetadata { get; set; }
|
||||
|
||||
[Key(13)]
|
||||
public TMetadata? AuditMetadata { get; set; }
|
||||
|
||||
[Key(14)]
|
||||
public List<TMetadata> MetadataList { get; set; } = [];
|
||||
|
||||
[JsonNoMergeCollection]
|
||||
[Key(15)]
|
||||
public List<TItem> NoMergeItems { get; set; } = [];
|
||||
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public object? Parent { get; set; }
|
||||
}
|
||||
|
||||
public abstract class TestOrderItemBase<TPallet, TTag, TUser, TMetadata, TParentOrder, TPreferences> : IId<int>
|
||||
where TPallet : class
|
||||
where TTag : SharedTagBase
|
||||
where TUser : SharedUserBase<TPreferences>
|
||||
where TMetadata : MetadataInfoBase<TMetadata>
|
||||
where TParentOrder : class
|
||||
where TPreferences : UserPreferencesBase
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[AcStringIntern(true)]
|
||||
[Key(1)]
|
||||
public string ProductName { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public int Quantity { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Key(4)]
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
|
||||
[Key(5)]
|
||||
public List<TPallet> Pallets { get; set; } = [];
|
||||
|
||||
[Key(6)]
|
||||
public TTag? Tag { get; set; }
|
||||
|
||||
[Key(7)]
|
||||
public TUser? Assignee { get; set; }
|
||||
|
||||
[Key(8)]
|
||||
public TMetadata? ItemMetadata { get; set; }
|
||||
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TParentOrder? ParentOrder { get; set; }
|
||||
}
|
||||
|
||||
public abstract class TestPalletBase<TMeasurement, TTag, TUser, TCategory, TMetadata, TParentItem, TPreferences> : IId<int>
|
||||
where TMeasurement : class
|
||||
where TTag : SharedTagBase
|
||||
where TUser : SharedUserBase<TPreferences>
|
||||
where TCategory : SharedCategoryBase
|
||||
where TMetadata : MetadataInfoBase<TMetadata>
|
||||
where TParentItem : class
|
||||
where TPreferences : UserPreferencesBase
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string PalletCode { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public int TrayCount { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
|
||||
[Key(4)]
|
||||
public double Weight { get; set; }
|
||||
|
||||
[Key(5)]
|
||||
public List<TMeasurement> Measurements { get; set; } = [];
|
||||
|
||||
[Key(6)]
|
||||
public TTag? Tag { get; set; }
|
||||
|
||||
[Key(7)]
|
||||
public TUser? Inspector { get; set; }
|
||||
|
||||
[Key(8)]
|
||||
public TCategory? Category { get; set; }
|
||||
|
||||
[Key(9)]
|
||||
public TMetadata? PalletMetadata { get; set; }
|
||||
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TParentItem? ParentItem { get; set; }
|
||||
}
|
||||
|
||||
public abstract class TestMeasurementBase<TPoint, TTag, TUser, TParentPallet, TPreferences> : IId<int>
|
||||
where TPoint : class
|
||||
where TTag : SharedTagBase
|
||||
where TUser : SharedUserBase<TPreferences>
|
||||
where TParentPallet : class
|
||||
where TPreferences : UserPreferencesBase
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public double TotalWeight { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Key(4)]
|
||||
public List<TPoint> Points { get; set; } = [];
|
||||
|
||||
[Key(5)]
|
||||
public TTag? Tag { get; set; }
|
||||
|
||||
[Key(6)]
|
||||
public TUser? Operator { get; set; }
|
||||
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TParentPallet? ParentPallet { get; set; }
|
||||
}
|
||||
|
||||
public abstract class TestMeasurementPointBase<TTag, TUser, TParentMeasurement, TPreferences> : IId<int>
|
||||
where TTag : SharedTagBase
|
||||
where TUser : SharedUserBase<TPreferences>
|
||||
where TParentMeasurement : class
|
||||
where TPreferences : UserPreferencesBase
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
[Key(2)]
|
||||
public double Value { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public DateTime MeasuredAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Key(4)]
|
||||
public TTag? Tag { get; set; }
|
||||
|
||||
[Key(5)]
|
||||
public TUser? Verifier { get; set; }
|
||||
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TParentMeasurement? ParentMeasurement { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -48,204 +48,8 @@ public enum TestUserRole
|
|||
|
||||
#endregion
|
||||
|
||||
#region Shared Reference Types (IId-based for $id/$ref testing)
|
||||
|
||||
/// <summary>
|
||||
/// Shared tag/label - used across multiple entities for cross-reference testing.
|
||||
/// Implements IId<int> for semantic $id/$ref serialization.
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class SharedTag : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
[AcStringIntern(true)]
|
||||
[Key(2)]
|
||||
public string Color { get; set; } = "#000000";
|
||||
[Key(3)]
|
||||
public int Priority { get; set; }
|
||||
[Key(4)]
|
||||
public bool IsActive { get; set; } = true;
|
||||
[Key(5)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(6)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared category - for hierarchical cross-reference testing.
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class SharedCategory : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
[Key(2)]
|
||||
public string? Description { get; set; }
|
||||
[Key(3)]
|
||||
public int SortOrder { get; set; }
|
||||
[Key(4)]
|
||||
public bool IsDefault { get; set; }
|
||||
[Key(5)]
|
||||
public int? ParentCategoryId { get; set; }
|
||||
[Key(6)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(7)]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared user reference - appears in many places to test $ref deduplication.
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class SharedUser : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Username { get; set; } = "";
|
||||
[Key(2)]
|
||||
public string Email { get; set; } = "";
|
||||
[Key(3)]
|
||||
public string FirstName { get; set; } = "";
|
||||
[Key(4)]
|
||||
public string LastName { get; set; } = "";
|
||||
[Key(5)]
|
||||
public bool IsActive { get; set; } = true;
|
||||
[Key(6)]
|
||||
public TestUserRole Role { get; set; } = TestUserRole.User;
|
||||
[Key(7)]
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
[Key(8)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(9)]
|
||||
public UserPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User preferences - non-IId nested object
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class UserPreferences
|
||||
{
|
||||
[AcStringIntern(true)]
|
||||
[Key(0)]
|
||||
public string Theme { get; set; } = "light";
|
||||
[AcStringIntern(true)]
|
||||
[Key(1)]
|
||||
public string Language { get; set; } = "en-US";
|
||||
[Key(2)]
|
||||
public bool NotificationsEnabled { get; set; } = true;
|
||||
[AcStringIntern(true)]
|
||||
[Key(3)]
|
||||
public string? EmailDigestFrequency { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Non-IId Metadata (Newtonsoft numeric $id/$ref testing)
|
||||
|
||||
/// <summary>
|
||||
/// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref).
|
||||
/// Does NOT implement IId, so uses standard Newtonsoft reference tracking.
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class MetadataInfo
|
||||
{
|
||||
[AcStringIntern(true)]
|
||||
[Key(0)]
|
||||
public string Key { get; set; } = "";
|
||||
[AcStringIntern(true)]
|
||||
[Key(1)]
|
||||
public string Value { get; set; } = "";
|
||||
[Key(2)]
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Nested metadata for deep Newtonsoft reference testing
|
||||
/// </summary>
|
||||
[Key(3)]
|
||||
public MetadataInfo? ChildMetadata { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 5-Level Test Hierarchy (Order -> Item -> Pallet -> Measurement -> Point)
|
||||
|
||||
/// <summary>
|
||||
/// Level 1: Main order - root of the hierarchy
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class TestOrder : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string OrderNumber { get; set; } = "";
|
||||
[Key(2)]
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
[Key(3)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
[Key(4)]
|
||||
public DateTime? PaidDateUtc { get; set; }
|
||||
[Key(5)]
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
// Level 2 collection
|
||||
[Key(6)]
|
||||
public List<TestOrderItem> Items { get; set; } = [];
|
||||
|
||||
// Shared reference properties (for $id/$ref testing)
|
||||
[Key(7)]
|
||||
public SharedTag? PrimaryTag { get; set; }
|
||||
[Key(8)]
|
||||
public SharedTag? SecondaryTag { get; set; }
|
||||
[Key(9)]
|
||||
public SharedUser? Owner { get; set; }
|
||||
[Key(10)]
|
||||
public SharedCategory? Category { get; set; }
|
||||
|
||||
// Collection of shared references
|
||||
[Key(11)]
|
||||
public List<SharedTag> Tags { get; set; } = [];
|
||||
|
||||
// Non-IId metadata (for Newtonsoft $ref testing)
|
||||
[Key(12)]
|
||||
public MetadataInfo? OrderMetadata { get; set; }
|
||||
[Key(13)]
|
||||
public MetadataInfo? AuditMetadata { get; set; }
|
||||
[Key(14)]
|
||||
public List<MetadataInfo> MetadataList { get; set; } = [];
|
||||
|
||||
// NoMerge collection for testing replace behavior
|
||||
[JsonNoMergeCollection]
|
||||
[Key(15)]
|
||||
public List<TestOrderItem> NoMergeItems { get; set; } = [];
|
||||
|
||||
// Parent reference - ignored by all serializers to prevent circular references
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public object? Parent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 1: Main order - root of the hierarchy
|
||||
/// </summary>
|
||||
|
|
@ -263,16 +67,16 @@ public partial class TestOrder_Circ_Ref : IId<int>
|
|||
public List<TestOrderItem_Circ_Ref> Items { get; set; } = [];
|
||||
|
||||
// Shared reference properties (for $id/$ref testing)
|
||||
public SharedTag? PrimaryTag { get; set; }
|
||||
public SharedTag? SecondaryTag { get; set; }
|
||||
public SharedUser? Owner { get; set; }
|
||||
public SharedCategory? Category { get; set; }
|
||||
public SharedTag_All_True? PrimaryTag { get; set; }
|
||||
public SharedTag_All_True? SecondaryTag { get; set; }
|
||||
public SharedUser_All_True? Owner { get; set; }
|
||||
public SharedCategory_All_True? Category { get; set; }
|
||||
|
||||
// Collection of shared references
|
||||
public List<SharedTag> Tags { get; set; } = [];
|
||||
public MetadataInfo? OrderMetadata { get; set; }
|
||||
public MetadataInfo? AuditMetadata { get; set; }
|
||||
public List<MetadataInfo> MetadataList { get; set; } = [];
|
||||
public List<SharedTag_All_True> Tags { get; set; } = [];
|
||||
public MetadataInfo_All_True? OrderMetadata { get; set; }
|
||||
public MetadataInfo_All_True? AuditMetadata { get; set; }
|
||||
public List<MetadataInfo_All_True> MetadataList { get; set; } = [];
|
||||
|
||||
// NoMerge collection for testing replace behavior
|
||||
[JsonNoMergeCollection]
|
||||
|
|
@ -280,46 +84,6 @@ public partial class TestOrder_Circ_Ref : IId<int>
|
|||
|
||||
public object? Parent { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Level 2: Order item with pallets
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class TestOrderItem : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[AcStringIntern(true)]
|
||||
[Key(1)]
|
||||
public string ProductName { get; set; } = "";
|
||||
[Key(2)]
|
||||
public int Quantity { get; set; }
|
||||
[Key(3)]
|
||||
public decimal UnitPrice { get; set; }
|
||||
[Key(4)]
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
|
||||
// Level 3 collection
|
||||
[Key(5)]
|
||||
public List<TestPallet> Pallets { get; set; } = [];
|
||||
|
||||
// Shared references
|
||||
[Key(6)]
|
||||
public SharedTag? Tag { get; set; }
|
||||
[Key(7)]
|
||||
public SharedUser? Assignee { get; set; }
|
||||
[Key(8)]
|
||||
public MetadataInfo? ItemMetadata { get; set; }
|
||||
|
||||
// Parent reference - ignored by all serializers to prevent circular references
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TestOrder? ParentOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 2: Order item with pallets
|
||||
/// </summary>
|
||||
|
|
@ -334,124 +98,15 @@ public partial class TestOrderItem_Circ_Ref : IId<int>
|
|||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
|
||||
// Level 3 collection
|
||||
public List<TestPallet> Pallets { get; set; } = [];
|
||||
public List<TestPallet_All_True> Pallets { get; set; } = [];
|
||||
|
||||
// Shared references
|
||||
public SharedTag? Tag { get; set; }
|
||||
public SharedUser? Assignee { get; set; }
|
||||
public MetadataInfo? ItemMetadata { get; set; }
|
||||
public SharedTag_All_True? Tag { get; set; }
|
||||
public SharedUser_All_True? Assignee { get; set; }
|
||||
public MetadataInfo_All_True? ItemMetadata { get; set; }
|
||||
|
||||
public TestOrder_Circ_Ref? ParentOrder { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Level 3: Pallet containing measurements
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class TestPallet : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string PalletCode { get; set; } = "";
|
||||
[Key(2)]
|
||||
public int TrayCount { get; set; }
|
||||
[Key(3)]
|
||||
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||
[Key(4)]
|
||||
public double Weight { get; set; }
|
||||
|
||||
// Level 4 collection
|
||||
[Key(5)]
|
||||
public List<TestMeasurement> Measurements { get; set; } = [];
|
||||
|
||||
// Shared IId references for better reference testing
|
||||
[Key(6)]
|
||||
public SharedTag? Tag { get; set; }
|
||||
[Key(7)]
|
||||
public SharedUser? Inspector { get; set; }
|
||||
[Key(8)]
|
||||
public SharedCategory? Category { get; set; }
|
||||
|
||||
// Non-IId shared references
|
||||
[Key(9)]
|
||||
public MetadataInfo? PalletMetadata { get; set; }
|
||||
|
||||
// Parent reference - ignored by all serializers to prevent circular references
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TestOrderItem? ParentItem { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 4: Measurement with multiple points
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class TestMeasurement : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
[Key(2)]
|
||||
public double TotalWeight { get; set; }
|
||||
[Key(3)]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Level 5 collection
|
||||
[Key(4)]
|
||||
public List<TestMeasurementPoint> Points { get; set; } = [];
|
||||
|
||||
// Shared IId references for better reference testing
|
||||
[Key(5)]
|
||||
public SharedTag? Tag { get; set; }
|
||||
[Key(6)]
|
||||
public SharedUser? Operator { get; set; }
|
||||
|
||||
// Parent reference - ignored by all serializers to prevent circular references
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TestPallet? ParentPallet { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 5: Deepest level - measurement point
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public partial class TestMeasurementPoint : IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
[Key(1)]
|
||||
public string Label { get; set; } = "";
|
||||
[Key(2)]
|
||||
public double Value { get; set; }
|
||||
[Key(3)]
|
||||
public DateTime MeasuredAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Shared IId reference for better reference testing (many points share same tag/user)
|
||||
[Key(4)]
|
||||
public SharedTag? Tag { get; set; }
|
||||
[Key(5)]
|
||||
public SharedUser? Verifier { get; set; }
|
||||
|
||||
// Parent reference - ignored by all serializers to prevent circular references
|
||||
[MemoryPackIgnore]
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
[BsonIgnore]
|
||||
public TestMeasurement? ParentMeasurement { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Guid-based IId types
|
||||
|
|
@ -515,7 +170,7 @@ public class TestOrderWithNullableCollections
|
|||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = "";
|
||||
public List<TestOrderItem>? Items { get; set; }
|
||||
public List<TestOrderItem_All_True>? Items { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -567,10 +222,10 @@ public class ExtendedPrimitiveTestClass
|
|||
|
||||
// Nullable properties that will be null
|
||||
public string? NullString { get; set; }
|
||||
public TestOrderItem? NullObject { get; set; }
|
||||
public TestOrderItem_All_True? NullObject { get; set; }
|
||||
|
||||
// Nested object for complex serialization
|
||||
public SharedTag? Tag { get; set; }
|
||||
public SharedTag_All_True? Tag { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using MemoryPack;
|
||||
using MessagePack;
|
||||
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
// ============================================================================
|
||||
// _All_True family — every leaf marked [AcBinarySerializable(true)] (opt-out).
|
||||
// All sub-references are _All_True-typed via the generic closing.
|
||||
// `sealed` to enable AcBinary's non-polymorphic fast-path (no type-discriminator).
|
||||
// ============================================================================
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class SharedTag_All_True : SharedTagBase
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class SharedCategory_All_True : SharedCategoryBase
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class SharedUser_All_True : SharedUserBase<UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class UserPreferences_All_True : UserPreferencesBase
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class MetadataInfo_All_True : MetadataInfoBase<MetadataInfo_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 1: Main order - root of the hierarchy
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestOrder_All_True
|
||||
: TestOrderBase<TestOrderItem_All_True, SharedTag_All_True, SharedUser_All_True,
|
||||
SharedCategory_All_True, MetadataInfo_All_True, UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 2: Order item with pallets
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestOrderItem_All_True
|
||||
: TestOrderItemBase<TestPallet_All_True, SharedTag_All_True, SharedUser_All_True,
|
||||
MetadataInfo_All_True, TestOrder_All_True, UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 3: Pallet containing measurements
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestPallet_All_True
|
||||
: TestPalletBase<TestMeasurement_All_True, SharedTag_All_True, SharedUser_All_True,
|
||||
SharedCategory_All_True, MetadataInfo_All_True, TestOrderItem_All_True,
|
||||
UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 4: Measurement with multiple points
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestMeasurement_All_True
|
||||
: TestMeasurementBase<TestMeasurementPoint_All_True, SharedTag_All_True, SharedUser_All_True,
|
||||
TestPallet_All_True, UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 5: Deepest level - measurement point
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestMeasurementPoint_All_True
|
||||
: TestMeasurementPointBase<SharedTag_All_True, SharedUser_All_True, TestMeasurement_All_True,
|
||||
UserPreferences_All_True>
|
||||
{
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _All_False family — every leaf marked [AcBinarySerializable(false)] (opt-in).
|
||||
// All sub-references are _All_False-typed via the generic closing.
|
||||
// `sealed` to enable AcBinary's non-polymorphic fast-path.
|
||||
// ============================================================================
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class SharedTag_All_False : SharedTagBase
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class SharedCategory_All_False : SharedCategoryBase
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class SharedUser_All_False : SharedUserBase<UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class UserPreferences_All_False : UserPreferencesBase
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class MetadataInfo_All_False : MetadataInfoBase<MetadataInfo_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestOrder_All_False
|
||||
: TestOrderBase<TestOrderItem_All_False, SharedTag_All_False, SharedUser_All_False,
|
||||
SharedCategory_All_False, MetadataInfo_All_False, UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestOrderItem_All_False
|
||||
: TestOrderItemBase<TestPallet_All_False, SharedTag_All_False, SharedUser_All_False,
|
||||
MetadataInfo_All_False, TestOrder_All_False, UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestPallet_All_False
|
||||
: TestPalletBase<TestMeasurement_All_False, SharedTag_All_False, SharedUser_All_False,
|
||||
SharedCategory_All_False, MetadataInfo_All_False, TestOrderItem_All_False,
|
||||
UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestMeasurement_All_False
|
||||
: TestMeasurementBase<TestMeasurementPoint_All_False, SharedTag_All_False, SharedUser_All_False,
|
||||
TestPallet_All_False, UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestMeasurementPoint_All_False
|
||||
: TestMeasurementPointBase<SharedTag_All_False, SharedUser_All_False, TestMeasurement_All_False,
|
||||
UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MIXED family — drift reproduction (SGen-emit asymmetry check).
|
||||
// Mirrors the FruitBank ProductDto / OrderDto / GenericAttributeDto attribute:
|
||||
// [AcBinarySerializable(false, true, false, true, false, false)]
|
||||
// meta=false, IdTracking=true, RefHandling=FALSE, Intern=true, Filter=false, Poly=false
|
||||
//
|
||||
// Parent: EnableRefHandlingFeature=FALSE ◀ the asymmetry trigger
|
||||
// Child: EnableRefHandlingFeature=true (IId<int>, all features ON)
|
||||
//
|
||||
// Hypothesis (confirmed): the SGen reader-emit guard for collection-element / Complex /
|
||||
// dictionary-value dispatch (EmitReadCollectionElement / EmitReadComplex / EmitReadDictionary)
|
||||
// used to check the PARENT-level enableRefHandling flag. The writer-emit only depends on
|
||||
// CHILD-level flags (ElementNeedsRefScan / DictValueNeedsRefScan / runtime
|
||||
// UseTypeReferenceHandling). With runtime ReferenceHandling=All + duplicate child instances,
|
||||
// the writer runtime emits ObjectRefFirst / ObjectRef, but the reader's zero-branch path
|
||||
// couldn't decode them → DECIMAL_DRIFT on MarkerDecimal after the Children list or
|
||||
// ChildrenMap dictionary.
|
||||
//
|
||||
// Fix: parent-flag removed from reader guards; routing through RefAwareEmitPredicate
|
||||
// (single source of truth shared with writer-side EmitDirectCollectionWrite).
|
||||
//
|
||||
// The existing _All_True family tests don't exercise this path because
|
||||
// EnableRefHandlingFeature=true on the parent → reader emitted the full
|
||||
// ref-aware switch → never hit the zero-branch bug.
|
||||
// ============================================================================
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(false, true, false, true, false, false)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestRefAsymParent : AyCode.Core.Interfaces.IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public System.Collections.Generic.List<TestRefAsymChild>? Children { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public decimal MarkerDecimal { get; set; }
|
||||
|
||||
[Key(3)]
|
||||
public System.Collections.Generic.Dictionary<int, TestRefAsymChild>? ChildrenMap { get; set; }
|
||||
|
||||
[Key(4)]
|
||||
public decimal MarkerDecimal2 { get; set; }
|
||||
}
|
||||
|
||||
[MemoryPackable]
|
||||
[AcBinarySerializable(true)]
|
||||
[MessagePackObject]
|
||||
public sealed partial class TestRefAsymChild : AyCode.Core.Interfaces.IId<int>
|
||||
{
|
||||
[Key(0)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
|
@ -236,8 +236,8 @@ public class SignalRBenchmarkData
|
|||
public byte[] MixedParamsMessage { get; }
|
||||
|
||||
// Test data
|
||||
public TestOrderItem TestOrderItem { get; }
|
||||
public TestOrder TestOrder { get; }
|
||||
public TestOrderItem_All_True TestOrderItem { get; }
|
||||
public TestOrder_All_True TestOrder { get; }
|
||||
public int[] IntArray { get; }
|
||||
public Guid TestGuid { get; }
|
||||
|
||||
|
|
@ -246,7 +246,7 @@ public class SignalRBenchmarkData
|
|||
// Create test data
|
||||
TestGuid = Guid.NewGuid();
|
||||
IntArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
TestOrderItem = new TestOrderItem
|
||||
TestOrderItem = new TestOrderItem_All_True
|
||||
{
|
||||
Id = 42,
|
||||
ProductName = "Benchmark Product",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,43 @@
|
|||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating test data hierarchies.
|
||||
/// Used by both unit tests and benchmarks.
|
||||
/// Generic factory for the 5-level test-data hierarchy (Order → OrderItem → Pallet → Measurement →
|
||||
/// MeasurementPoint) + cross-cutting shared types (Tag, Category, User, Metadata, UserPreferences).
|
||||
/// One closing-generic alias per family — see <see cref="TestDataFactory"/> (the <c>_All_True</c>
|
||||
/// family, kept on the bare class name for MSTEST backward compatibility) and
|
||||
/// <see cref="TestDataFactory_All_False"/> (the <c>_All_False</c> family).
|
||||
///
|
||||
/// <para>The static <c>_idCounter</c> below is per-closed-generic (verified via C# smoke): each family
|
||||
/// has an independent ID sequence, so calls like <c>TestDataFactory.ResetIdCounter()</c> reset only the
|
||||
/// <c>_All_True</c> counter, leaving any <c>TestDataFactory_All_False.NextId()</c> sequence intact.
|
||||
/// Each family's "Reset → Next" pattern stays internally consistent.</para>
|
||||
///
|
||||
/// <para>All placeholder strings use Hungarian (UTF-8 multi-byte) content to exercise the UTF-8
|
||||
/// encoder/decoder path rather than the ASCII fast-path. This makes the benchmark reflect realistic
|
||||
/// i18n payloads, not just the FixStrAscii / StringAscii marker fast-paths.</para>
|
||||
/// </summary>
|
||||
public static class TestDataFactory
|
||||
public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>
|
||||
where TOrder : TestOrderBase<TItem, TTag, TUser, TCategory, TMetadata, TPreferences>, new()
|
||||
where TItem : TestOrderItemBase<TPallet, TTag, TUser, TMetadata, TOrder, TPreferences>, new()
|
||||
where TPallet : TestPalletBase<TMeasurement, TTag, TUser, TCategory, TMetadata, TItem, TPreferences>, new()
|
||||
where TMeasurement : TestMeasurementBase<TPoint, TTag, TUser, TPallet, TPreferences>, new()
|
||||
where TPoint : TestMeasurementPointBase<TTag, TUser, TMeasurement, TPreferences>, new()
|
||||
where TTag : SharedTagBase, new()
|
||||
where TUser : SharedUserBase<TPreferences>, new()
|
||||
where TCategory : SharedCategoryBase, new()
|
||||
where TMetadata : MetadataInfoBase<TMetadata>, new()
|
||||
where TPreferences : UserPreferencesBase, new()
|
||||
{
|
||||
private static int _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Reset the ID counter (call in test setup)
|
||||
/// Reset the ID counter (call in test setup). Resets ONLY this family's counter — sibling families
|
||||
/// keep their own independent counter state.
|
||||
/// </summary>
|
||||
public static void ResetIdCounter() => _idCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Get the next unique ID
|
||||
/// Get the next unique ID. Per-family counter — see class docs for the isolation rationale.
|
||||
/// </summary>
|
||||
public static int NextId() => _idCounter++;
|
||||
|
||||
|
|
@ -23,28 +46,28 @@ public static class TestDataFactory
|
|||
/// <summary>
|
||||
/// Create a shared tag for cross-reference testing
|
||||
/// </summary>
|
||||
public static SharedTag CreateTag(string? name = null, string? color = null)
|
||||
public static TTag CreateTag(string? name = null, string? color = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedTag
|
||||
return new TTag
|
||||
{
|
||||
Id = id,
|
||||
Name = name ?? $"Tag-{id}",
|
||||
Color = color ?? $"#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
|
||||
Color = color ?? $"Color-#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
|
||||
Priority = id % 5,
|
||||
IsActive = id % 2 == 0,
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-id),
|
||||
Description = $"Description for tag {id}"
|
||||
Description = $"Tag description {id}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a shared category
|
||||
/// </summary>
|
||||
public static SharedCategory CreateCategory(string? name = null, int? parentId = null)
|
||||
public static TCategory CreateCategory(string? name = null, int? parentId = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedCategory
|
||||
return new TCategory
|
||||
{
|
||||
Id = id,
|
||||
Name = name ?? $"Category-{id}",
|
||||
|
|
@ -60,24 +83,24 @@ public static class TestDataFactory
|
|||
/// <summary>
|
||||
/// Create a shared user for cross-reference testing
|
||||
/// </summary>
|
||||
public static SharedUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User)
|
||||
public static TUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new SharedUser
|
||||
return new TUser
|
||||
{
|
||||
Id = id,
|
||||
Username = username ?? $"user{id}",
|
||||
Email = $"user{id}@test.com",
|
||||
FirstName = $"First{id}",
|
||||
LastName = $"Last{id}",
|
||||
FirstName = $"FirstName{id}",
|
||||
LastName = $"LastName{id}",
|
||||
IsActive = true,
|
||||
Role = role,
|
||||
LastLoginAt = DateTime.UtcNow.AddHours(-id),
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-1),
|
||||
Preferences = new UserPreferences
|
||||
Preferences = new TPreferences
|
||||
{
|
||||
Theme = id % 2 == 0 ? "dark" : "light",
|
||||
Language = "en-US",
|
||||
Language = "english",
|
||||
NotificationsEnabled = true,
|
||||
EmailDigestFrequency = "daily"
|
||||
}
|
||||
|
|
@ -87,13 +110,13 @@ public static class TestDataFactory
|
|||
/// <summary>
|
||||
/// Create metadata info (non-IId)
|
||||
/// </summary>
|
||||
public static MetadataInfo CreateMetadata(string? key = null, bool withChild = false)
|
||||
public static TMetadata CreateMetadata(string? key = null, bool withChild = false)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new MetadataInfo
|
||||
return new TMetadata
|
||||
{
|
||||
Key = key ?? $"Meta-{id}",
|
||||
Value = $"MetaValue-{id}",
|
||||
Key = key ?? $"Metadata-{id}",
|
||||
Value = $"MetadataValue-{id}",
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(-id * 10),
|
||||
ChildMetadata = withChild ? CreateMetadata($"Child-{id}", false) : null
|
||||
};
|
||||
|
|
@ -105,26 +128,26 @@ public static class TestDataFactory
|
|||
|
||||
/// <summary>
|
||||
/// Create a deep order hierarchy with configurable depth.
|
||||
/// Supports both IId-based (SharedTag, SharedUser, SharedCategory) and Non-IId (UserPreferences) shared references.
|
||||
/// Supports both IId-based (Tag, User, Category) and Non-IId (Preferences) shared references.
|
||||
/// </summary>
|
||||
public static TestOrder CreateOrder(
|
||||
public static TOrder CreateOrder(
|
||||
int itemCount = 2,
|
||||
int palletsPerItem = 2,
|
||||
int measurementsPerPallet = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedUser = null,
|
||||
MetadataInfo? sharedMetadata = null,
|
||||
UserPreferences? sharedPreferences = null,
|
||||
SharedCategory? sharedCategory = null)
|
||||
TTag? sharedTag = null,
|
||||
TUser? sharedUser = null,
|
||||
TMetadata? sharedMetadata = null,
|
||||
TPreferences? sharedPreferences = null,
|
||||
TCategory? sharedCategory = null)
|
||||
{
|
||||
// If sharedUser is provided but no sharedPreferences, use the user's preferences as shared
|
||||
sharedPreferences ??= sharedUser?.Preferences;
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TOrder
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"ORD-{_idCounter:D4}",
|
||||
OrderNumber = $"Order-{_idCounter:D4}",
|
||||
Status = TestStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 1000m + _idCounter * 100,
|
||||
|
|
@ -161,20 +184,19 @@ public static class TestDataFactory
|
|||
|
||||
/// <summary>
|
||||
/// Create an order item with pallets.
|
||||
/// Supports both IId-based and Non-IId shared references.
|
||||
/// </summary>
|
||||
public static TestOrderItem CreateOrderItem(
|
||||
public static TItem CreateOrderItem(
|
||||
int palletCount = 2,
|
||||
int measurementsPerPallet = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedUser = null,
|
||||
MetadataInfo? sharedMetadata = null,
|
||||
UserPreferences? sharedPreferences = null,
|
||||
SharedCategory? sharedCategory = null)
|
||||
TTag? sharedTag = null,
|
||||
TUser? sharedUser = null,
|
||||
TMetadata? sharedMetadata = null,
|
||||
TPreferences? sharedPreferences = null,
|
||||
TCategory? sharedCategory = null)
|
||||
{
|
||||
// Create assignee - if sharedUser provided, use it. Otherwise create new user with sharedPreferences
|
||||
SharedUser? assignee = sharedUser;
|
||||
TUser? assignee = sharedUser;
|
||||
if (assignee == null && sharedPreferences != null)
|
||||
{
|
||||
// Create a new user but with shared preferences (Non-IId ref testing)
|
||||
|
|
@ -182,7 +204,7 @@ public static class TestDataFactory
|
|||
assignee.Preferences = sharedPreferences;
|
||||
}
|
||||
|
||||
var item = new TestOrderItem
|
||||
var item = new TItem
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"Product-{_idCounter}",
|
||||
|
|
@ -211,24 +233,21 @@ public static class TestDataFactory
|
|||
return item;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Create a pallet with measurements
|
||||
/// </summary>
|
||||
public static TestPallet CreatePallet(
|
||||
public static TPallet CreatePallet(
|
||||
int measurementCount = 2,
|
||||
int pointsPerMeasurement = 3,
|
||||
MetadataInfo? sharedMetadata = null,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedInspector = null,
|
||||
SharedCategory? sharedCategory = null)
|
||||
TMetadata? sharedMetadata = null,
|
||||
TTag? sharedTag = null,
|
||||
TUser? sharedInspector = null,
|
||||
TCategory? sharedCategory = null)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
var pallet = new TPallet
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"PLT-{_idCounter:D4}",
|
||||
PalletCode = $"PalletCode-{_idCounter:D4}",
|
||||
TrayCount = 5 + _idCounter % 10,
|
||||
Status = TestStatus.Pending,
|
||||
Weight = 100.5 + _idCounter,
|
||||
|
|
@ -251,12 +270,12 @@ public static class TestDataFactory
|
|||
/// <summary>
|
||||
/// Create a measurement with points
|
||||
/// </summary>
|
||||
public static TestMeasurement CreateMeasurement(
|
||||
public static TMeasurement CreateMeasurement(
|
||||
int pointCount = 3,
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedOperator = null)
|
||||
TTag? sharedTag = null,
|
||||
TUser? sharedOperator = null)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
var measurement = new TMeasurement
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Measurement-{_idCounter}",
|
||||
|
|
@ -279,15 +298,15 @@ public static class TestDataFactory
|
|||
/// <summary>
|
||||
/// Create a measurement point
|
||||
/// </summary>
|
||||
public static TestMeasurementPoint CreateMeasurementPoint(
|
||||
SharedTag? sharedTag = null,
|
||||
SharedUser? sharedVerifier = null)
|
||||
public static TPoint CreateMeasurementPoint(
|
||||
TTag? sharedTag = null,
|
||||
TUser? sharedVerifier = null)
|
||||
{
|
||||
var id = _idCounter++;
|
||||
return new TestMeasurementPoint
|
||||
return new TPoint
|
||||
{
|
||||
Id = id,
|
||||
Label = $"Point-{id}",
|
||||
Label = $"MeasurePoint-{id}",
|
||||
Value = 10.5 + (id * 0.1),
|
||||
MeasuredAt = DateTime.UtcNow,
|
||||
Tag = sharedTag,
|
||||
|
|
@ -296,14 +315,29 @@ public static class TestDataFactory
|
|||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Benchmark Data Generation
|
||||
// ============================================================================================
|
||||
// Closing-generic aliases. Each family carries its own static <c>_idCounter</c> (per-closed-generic
|
||||
// isolation — see C# runtime semantics). The base-class generic methods are accessible through both
|
||||
// aliases unchanged.
|
||||
// ============================================================================================
|
||||
|
||||
/// <summary>
|
||||
/// <c>_All_True</c> family factory — preserves the bare-name API surface
|
||||
/// (<c>TestDataFactory.CreateTag(...)</c>, etc.) that the MSTEST tests and benchmark consumers depend
|
||||
/// on. Adds family-specific extras (<see cref="CreateBenchmarkOrder"/>, <see cref="CreateLargeScaleBenchmarkOrder"/>,
|
||||
/// <see cref="CreatePrimitiveTestData"/>) that the generic base intentionally doesn't carry.
|
||||
/// </summary>
|
||||
public sealed class TestDataFactory : TestDataFactory<
|
||||
TestOrder_All_True, TestOrderItem_All_True, TestPallet_All_True, TestMeasurement_All_True, TestMeasurementPoint_All_True,
|
||||
SharedTag_All_True, SharedUser_All_True, SharedCategory_All_True, MetadataInfo_All_True, UserPreferences_All_True>
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a large graph for benchmarking with many cross-references.
|
||||
/// Creates approximately (itemCount * palletsPerItem * measurementsPerPallet * pointsPerMeasurement) objects.
|
||||
/// </summary>
|
||||
public static TestOrder CreateBenchmarkOrder(
|
||||
public static TestOrder_All_True CreateBenchmarkOrder(
|
||||
int itemCount = 5,
|
||||
int palletsPerItem = 4,
|
||||
int measurementsPerPallet = 3,
|
||||
|
|
@ -313,20 +347,20 @@ public static class TestDataFactory
|
|||
|
||||
// Create shared references that will be used throughout
|
||||
var sharedTags = Enumerable.Range(1, 10).Select(_ => CreateTag()).ToList();
|
||||
var sharedUser = CreateUser("benchuser", TestUserRole.Admin);
|
||||
var sharedMetadata = CreateMetadata("benchmark", withChild: true);
|
||||
var sharedUser = CreateUser("mérőfelhasználó", TestUserRole.Admin);
|
||||
var sharedMetadata = CreateMetadata("mérőteszt", withChild: true);
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"BENCH-{_idCounter:D6}",
|
||||
Id = NextId(),
|
||||
OrderNumber = $"MÉRŐTESZT-{NextId():D6}",
|
||||
Status = TestStatus.Processing,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 999999.99m,
|
||||
PrimaryTag = sharedTags[0],
|
||||
SecondaryTag = sharedTags[0],
|
||||
Owner = sharedUser,
|
||||
Category = CreateCategory("Benchmark"),
|
||||
Category = CreateCategory("Mérőteszt"),
|
||||
OrderMetadata = sharedMetadata,
|
||||
AuditMetadata = sharedMetadata,
|
||||
Tags = sharedTags.Take(3).ToList()
|
||||
|
|
@ -334,10 +368,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int i = 0; i < itemCount; i++)
|
||||
{
|
||||
var item = new TestOrderItem
|
||||
var item = new TestOrderItem_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"BenchProduct-{i}",
|
||||
Id = NextId(),
|
||||
ProductName = $"MérőTermék-{i}",
|
||||
Quantity = 100 + i * 10,
|
||||
UnitPrice = 25.99m + i,
|
||||
Status = (TestStatus)(i % 5),
|
||||
|
|
@ -349,10 +383,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int p = 0; p < palletsPerItem; p++)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
var pallet = new TestPallet_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"PLT-{i}-{p}",
|
||||
Id = NextId(),
|
||||
PalletCode = $"Raklapkód-{i}-{p}",
|
||||
TrayCount = 10 + p,
|
||||
Status = (TestStatus)(p % 4),
|
||||
Weight = 500.0 + p * 50,
|
||||
|
|
@ -362,10 +396,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int m = 0; m < measurementsPerPallet; m++)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
var measurement = new TestMeasurement_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"Meas-{i}-{p}-{m}",
|
||||
Id = NextId(),
|
||||
Name = $"Mérés-{i}-{p}-{m}",
|
||||
TotalWeight = 50.0 + m * 10,
|
||||
CreatedAt = DateTime.UtcNow.AddMinutes(-m)
|
||||
};
|
||||
|
|
@ -373,10 +407,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int pt = 0; pt < pointsPerMeasurement; pt++)
|
||||
{
|
||||
var point = new TestMeasurementPoint
|
||||
var point = new TestMeasurementPoint_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Label = $"Pt-{i}-{p}-{m}-{pt}",
|
||||
Id = NextId(),
|
||||
Label = $"MérőPnt-{i}-{p}-{m}-{pt}",
|
||||
Value = 1.0 + pt * 0.5,
|
||||
MeasuredAt = DateTime.UtcNow.AddSeconds(-pt)
|
||||
};
|
||||
|
|
@ -397,12 +431,7 @@ public static class TestDataFactory
|
|||
/// Create a large-scale benchmark order similar to production workloads.
|
||||
/// Targets ~50,000-100,000+ IId objects with deep hierarchy and shared references.
|
||||
/// </summary>
|
||||
/// <param name="rootItemCount">Number of root items (default 500 for ~50K objects, use 2200 for production-like)</param>
|
||||
/// <param name="palletsPerItem">Pallets per item</param>
|
||||
/// <param name="measurementsPerPallet">Measurements per pallet</param>
|
||||
/// <param name="pointsPerMeasurement">Points per measurement</param>
|
||||
/// <returns>Large TestOrder with many IId references</returns>
|
||||
public static TestOrder CreateLargeScaleBenchmarkOrder(
|
||||
public static TestOrder_All_True CreateLargeScaleBenchmarkOrder(
|
||||
int rootItemCount = 500,
|
||||
int palletsPerItem = 3,
|
||||
int measurementsPerPallet = 3,
|
||||
|
|
@ -412,14 +441,14 @@ public static class TestDataFactory
|
|||
|
||||
// Create shared references - these will be heavily reused (tests $ref handling)
|
||||
var sharedTags = Enumerable.Range(1, 50).Select(_ => CreateTag()).ToList();
|
||||
var sharedUsers = Enumerable.Range(1, 20).Select(i => CreateUser($"user{i}", (TestUserRole)(i % 4))).ToList();
|
||||
var sharedMetadata = CreateMetadata("large-scale", withChild: true);
|
||||
var sharedCategories = Enumerable.Range(1, 10).Select(i => CreateCategory($"Cat-{i}")).ToList();
|
||||
var sharedUsers = Enumerable.Range(1, 20).Select(i => CreateUser($"felhasználó{i}", (TestUserRole)(i % 4))).ToList();
|
||||
var sharedMetadata = CreateMetadata("nagy-méretű", withChild: true);
|
||||
var sharedCategories = Enumerable.Range(1, 10).Select(i => CreateCategory($"Kategória-{i}")).ToList();
|
||||
|
||||
var order = new TestOrder
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
OrderNumber = $"LARGE-{_idCounter:D8}",
|
||||
Id = NextId(),
|
||||
OrderNumber = $"NAGYMÉRET-{NextId():D8}",
|
||||
Status = TestStatus.Processing,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TotalAmount = 9999999.99m,
|
||||
|
|
@ -434,10 +463,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int i = 0; i < rootItemCount; i++)
|
||||
{
|
||||
var item = new TestOrderItem
|
||||
var item = new TestOrderItem_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
ProductName = $"Product-{i}",
|
||||
Id = NextId(),
|
||||
ProductName = $"Termék-{i}",
|
||||
Quantity = 100 + i,
|
||||
UnitPrice = 10.99m + (i % 100),
|
||||
Status = (TestStatus)(i % 5),
|
||||
|
|
@ -449,10 +478,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int p = 0; p < palletsPerItem; p++)
|
||||
{
|
||||
var pallet = new TestPallet
|
||||
var pallet = new TestPallet_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
PalletCode = $"P-{i}-{p}",
|
||||
Id = NextId(),
|
||||
PalletCode = $"Raklapkód-{i}-{p}",
|
||||
TrayCount = 5 + (p % 10),
|
||||
Status = (TestStatus)(p % 4),
|
||||
Weight = 100.0 + p * 10,
|
||||
|
|
@ -462,10 +491,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int m = 0; m < measurementsPerPallet; m++)
|
||||
{
|
||||
var measurement = new TestMeasurement
|
||||
var measurement = new TestMeasurement_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Name = $"M-{i}-{p}-{m}",
|
||||
Id = NextId(),
|
||||
Name = $"Mérés-{i}-{p}-{m}",
|
||||
TotalWeight = 10.0 + m,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
|
@ -473,10 +502,10 @@ public static class TestDataFactory
|
|||
|
||||
for (int pt = 0; pt < pointsPerMeasurement; pt++)
|
||||
{
|
||||
var point = new TestMeasurementPoint
|
||||
var point = new TestMeasurementPoint_All_True
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Label = $"Pt-{i}-{p}-{m}-{pt}",
|
||||
Id = NextId(),
|
||||
Label = $"MérőPnt-{i}-{p}-{m}-{pt}",
|
||||
Value = pt * 0.1,
|
||||
MeasuredAt = DateTime.UtcNow
|
||||
};
|
||||
|
|
@ -518,7 +547,7 @@ public static class TestDataFactory
|
|||
DecimalValue = 12345.6789m,
|
||||
FloatValue = 1.5f,
|
||||
BoolValue = true,
|
||||
StringValue = "Test String ?? ????",
|
||||
StringValue = "Teszt Szöveg árvíztűrőtükörfúrógép",
|
||||
GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
|
||||
DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc),
|
||||
EnumValue = TestStatus.Shipped,
|
||||
|
|
@ -528,6 +557,16 @@ public static class TestDataFactory
|
|||
NullableIntNull = null
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>_All_False</c> family factory — benchmark Phase 1 target. The generic-base factory methods
|
||||
/// produce <c>_All_False</c>-typed graphs via the closed-generic <c>new T()</c> calls. No
|
||||
/// family-specific extras here (the legacy <see cref="TestDataFactory.CreateBenchmarkOrder"/> etc.
|
||||
/// stay on the <c>_All_True</c> alias because their existing consumers are <c>_All_True</c>-tied).
|
||||
/// </summary>
|
||||
public sealed class TestDataFactory_All_False : TestDataFactory<
|
||||
TestOrder_All_False, TestOrderItem_All_False, TestPallet_All_False, TestMeasurement_All_False, TestMeasurementPoint_All_False,
|
||||
SharedTag_All_False, SharedUser_All_False, SharedCategory_All_False, MetadataInfo_All_False, UserPreferences_All_False>
|
||||
{
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue