Repository navigation
Expand file tree
/
Copy pathprofiler_base.h
More file actions
2818 lines (2662 loc) · 138 KB
/
Copy pathprofiler_base.h
File metadata and controls
2818 lines (2662 loc) · 138 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Copyright (c) PyPTO Contributors.
* This program is free software, you can redistribute it and/or modify it under the terms and conditions of
* CANN Open Software License Agreement Version 2.0 (the "License").
* Please refer to the License for details. You may not use this file except in compliance with the License.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
* See LICENSE in the root of the software repository for the full text of the License.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* @file profiler_base.h
* @brief CRTP scaffolding shared by ChipSwimlane, PMU, DepGen, ArgsDump,
* and ScopeStats collectors.
*
* Owns the BufferPoolManager<Module>, drain/replenish mgmt thread(s) that
* poll AICPU ready queues and recycle collector-done buffers, and the
* collector poll thread(s).
*
* Module concept contract
* -----------------------
*
* Each profiling subsystem provides a `Module` struct (e.g., ChipSwimlaneModule,
* DumpModule, PmuModule) that supplies the data-layout traits the unified
* mgmt-loop algorithms (ProfilerAlgorithms<Module>) need. Required members:
*
* // Types
* using DataHeader = ...; // Shared-memory header (e.g. ChipSwimlaneDataHeader).
* using ReadyEntry = ...; // Per-AICPU-thread ready-queue entry.
* using ReadyBufferInfo = ...; // Hand-off struct to collector thread(s)
* // (carries dev/host ptrs, optional kind
* // discriminator, and the seq).
* using FreeQueue = ...; // Per-instance SPSC queue of free buffer
* // pointers; must expose `head`, `tail`,
* // `buffer_ptrs[kSlotCount]`.
*
* // Constants
* static constexpr int kBufferKinds; // ChipSwimlane=4, Dump=1, PMU=1.
* static constexpr uint32_t kReadyQueueSize; // Per-thread ready-queue depth.
* // Optional: host-side done ring depth (defaults to 1024).
* static constexpr uint32_t kHostPoolQueueSize;
* // Optional: shard-local per-kind recycled ring depth.
* // Defaults to kHostPoolQueueSize.
* static constexpr uint32_t kHostRecycledQueueSize;
* static constexpr uint32_t kSlotCount; // FreeQueue::buffer_ptrs[] length.
* static constexpr const char* kSubsystemName; // "PMU" / "ChipSwimlane" / "Dump".
* // Optional: CAPACITY of the drain / collector shard arrays (defaults to
* // 1). Bounds the shard arrays at compile time; the number of threads
* // actually started is the runtime min(aicpu_thread_num,
* // kMaxCollectorThreads), latched by ProfilerBase::set_aicpu_thread_num().
* // Subsystems whose only device-side producer is the orchestrator (DepGen,
* // ScopeStats) set this to 1: one drain thread scans every AICPU ready
* // queue and finds the single producer's, so extra shards would only ever
* // be empty.
* static constexpr int kMaxCollectorThreads;
* // Optional: refresh cached queue metadata before a replenish pass.
* template <typename Mgr>
* static void refresh_replenish_metadata(Mgr&, DataHeader*);
*
* // Header pointer cast (host_ptr → DataHeader*)
* static DataHeader* header_from_shm(void* shared_mem_host);
*
* // Per-kind alloc batch size for proactive_replenish's free-queue fallback.
* static int batch_size(int kind);
*
* // Optional: steady-state low-water mark for a shard-local recycled lane.
* // Two forms; the two-arg one wins if both are present:
* // static int recycled_warm_target(int kind);
* // static int recycled_warm_target(int kind, int shard_count);
* // Take the two-arg form when the target depends on how many shards share
* // the device's cores — with `shard_count` live shards each one owns
* // ceil(cores / shard_count) cores, so the target must grow as shards
* // shrink. Omitting both means no watermark (0).
*
* // Required only when kBufferKinds > 1: discriminate which recycled bin
* // a finished buffer belongs to. Single-kind modules omit this method;
* // ProfilerBase::consume passes 0 unconditionally for them.
* static int kind_of(const ReadyBufferInfo& info);
*
* // Resolve a popped ReadyEntry into the originating BufferState's
* // free_queue + the partially-filled ReadyBufferInfo. Algorithm fills in
* // host_buffer_ptr after a resolve_host_ptr lookup. Return std::nullopt to
* // reject the entry (e.g. invalid index); the drain path then retires and
* // counts it rather than delivering it.
* static std::optional<EntrySite<Module>> resolve_entry(
* void* shm_host, DataHeader*, int q, const ReadyEntry&);
*
* // Enumerate every (kind, instance) free_queue and its buffer size for
* // proactive_replenish to top up. Every instance of one kind has the same
* // buffer size, so completed buffers can move between collector shards.
* // Callback signature:
* // (int kind, FreeQueue* fq, size_t buffer_size).
* template <typename Cb>
* static void for_each_instance(void* shm_host, DataHeader*, Cb&&);
*
* Alloc policy
* ------------
*
* process_entry replenishes the originating free_queue from the
* current drain shard's local recycled pool. It does
* not allocate on the runtime hot path. When that pool
* is dry it reports the site back, and the drain loop
* retries it after every sweep — the only way a lane
* with no buffer left to publish can recover, since
* this top-up is otherwise entry-driven.
* proactive_replenish fills to kSlotCount across all instances before
* drain/collector threads start. If recycled is dry,
* it allocates one registered block and carves it
* into a batch of buffers.
* mgmt_replenish_loop routes collector-done buffers to same-kind lanes
* below their optional recycled watermarks, then tops
* up remaining deficits in batches of
* max(kSlotCount, gap). It never writes device
* free_queues, so the drain hot path remains
* allocation-free and owns all runtime free_queue
* publication — which makes every free_queue
* single-writer by structure, not by timing.
*
* These algorithms live in ProfilerAlgorithms<Module>; Module only
* supplies the data-access traits above. Implementors must NOT zero `count`
* (or any other AICPU-owned field) on the host side — AICPU is the sole
* writer to those fields and resets them itself on flush/drop/pop.
*
* Drain-path ownership
* --------------------
*
* A device ready entry is acknowledged — `queue_heads[q]` advanced — only once
* its payload is in the host shadow, so the device owns the slot for as long as
* the host might still fail to read it and a transport failure costs a retry
* rather than the record. Two outcomes end that retry loop:
*
* - An entry that cannot be resolved, or whose buffer this manager never
* mapped, can never be read; retrying it would block the queue forever and
* quiesce() would never complete. It is acknowledged and counted.
* - An entry whose copy keeps failing is acknowledged and counted once it has
* held the queue head for kStalledDrainEntryTimeout.
*
* Either way `drain_dropped_buffers()` records it, so a reconcile gap is
* attributable to the host instead of being an anonymous silent loss, and the
* buffer goes back to the free_queue it came from (or to the manager's retired
* pool when that queue is full) so the pool does not shrink. The unresolvable
* entry is the exception: its indices did not validate, so its buffer is never
* published into a device-visible free_queue — it is parked in the retired pool
* when the manager can map it, and withheld entirely when it cannot.
*
* Lifecycle (the only correct teardown order):
* 1. Derived::init() binds callbacks with null shared-memory pointers before
* acquiring resources. A rollback guard owns every acquisition; only
* after success does publish_shared_memory() make the collector startable.
* Failed init returns an error after rollback, with shm_host_ still null.
* 2. start(tf) — launches the mgmt thread(s), then the collector thread(s).
* Mgmt is started before collectors because mgmt is the only writer to
* the host ready queue shard(s) and collectors are their consumers.
* 3. ... device execution ...
* 4. stop() — atomically:
* a) flips mgmt_running_, joins the mgmt thread(s); the drain thread's
* final-drain pass pushes the last device-ring entries into the host
* ready queue shard(s) before exiting.
* b) execution_complete_ is set and the ready-queue waiters are
* notified; each collector drains its host ready queue shard and
* exits.
* c) collector thread(s) joined.
* Caller is then guaranteed the device-side ring and the host ready queue
* shard(s) are both empty and all collected data has been delivered to
* Derived::on_buffer_collected.
*
* quiesce() gives that same guarantee without (a)'s and (c)'s joins, so
* the threads survive it — see its own comment.
*
* SVM vs host-shadow paths (chosen at runtime by the collector's MemoryOps)
* -------------------------------------------------------------------------
*
* - Collectors on platforms without SVM (a5: no halHostRegister) install
* `copy_to_device` / `copy_from_device` in MemoryOps so every device
* read/write goes through rtMemcpy (onboard) or memcpy (sim). The
* mgmt_loop then pulls the device-side shared-memory region into the
* host shadow at the top of every tick (`mirror_shm_from_device`) and
* pushes the few host-modified fields (`queue_heads[q]` after pop,
* `free_queue.tail` + `buffer_ptrs[]` after refill) back as narrow
* `write_range_to_device` writes. A bulk host→device write-back is
* intentionally avoided: it would race with AICPU writes to
* device-only fields (current_buf_ptr, current_buf_seq,
* total/dropped/mismatch counters, queue_tails, free_queue.head, and
* the subsystem's device-written header fields) and roll them back to the
* host-shadow values mirrored in at the top of the tick. Buffer
* contents are mirrored on demand inside ProfilerAlgorithms.
* - On these platforms `reg` always allocates a paired host shadow; the
* framework never falls back to identity-mapping (which would be wrong
* without SVM). Collectors pass nullptr-safe callbacks via
* Derived::init.
* - SVM platforms (a2a3: halHostRegister maps device pointers into host
* address space) leave `copy_to_device` / `copy_from_device` null and
* pass the same pointer as both shm_dev and shm_host. The mirror /
* write_range / copy_buffer / read_range methods then short-circuit
* via the manager's internal null-check and cost a single function
* call per tick (zero memcpy work).
*
* Required Derived contract
* -------------------------
*
* void on_buffer_collected(const ReadyBufferInfo& info);
* // Optional shard-aware overload:
* void on_buffer_collected(const ReadyBufferInfo& info, int collector_shard);
* Copy records out of `info.host_buffer_ptr` and update any
* per-collector state. The base class then calls
* `manager_.notify_copy_done(...)` so the buffer is recycled —
* Derived must NOT do that itself.
*
* static constexpr int kIdleTimeoutSec;
* Bound on how long the loop sits with no buffers AND no
* `execution_complete_` signal before logging an error. The collector
* remains alive until execution completes so a blocked drain producer
* always has a consumer (use the subsystem's PLATFORM_*_TIMEOUT_SECONDS).
*
* static constexpr const char* kSubsystemName;
* Used in the idle-timeout log line (e.g. "ChipSwimlane", "PMU", "ArgsDump").
*/
#pragma once
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <limits>
#include <optional>
#include <memory>
#include <mutex>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>
#include "common/memory_barrier.h"
#include "common/platform_config.h"
#include "common/unified_log.h"
#include "host/buffer_pool_manager.h"
#include "host/chip_swimlane_runs.h"
#include "host/profiling_copy.h"
#include "../../../worker/runtime_c_api.h"
namespace profiling_common {
template <typename Derived, typename ReadyBufferInfo, typename = void>
struct ProfilerDerivedShardAwareCollector {
static constexpr bool value = false;
};
template <typename Derived, typename ReadyBufferInfo>
struct ProfilerDerivedShardAwareCollector<
Derived, ReadyBufferInfo,
std::void_t<decltype(std::declval<Derived *>()
->on_buffer_collected(std::declval<const ReadyBufferInfo &>(), std::declval<int>()))>> {
static constexpr bool value = true;
};
// Common subsystem callback signatures. All four collectors (PMU / ArgsDump
// / ChipSwimlane / DepGen) used to declare their own typedefs with identical
// shapes; these are the canonical types stashed in ProfilerBase via
// set_memory_context().
//
// Alloc / free use std::function so callers can bind state (e.g. their
// MemoryAllocator) directly via lambda capture. Register / unregister stay
// as plain function pointers — they wrap stateless HAL globals (halHost*),
// so the captureless C-callback shape matches their actual nature.
using ProfAllocCallback = std::function<void *(size_t size)>;
using ProfRegisterCallback = int (*)(void *dev_ptr, size_t size, int device_id, void **host_ptr_out);
using ProfUnregisterCallback = int (*)(void *dev_ptr, int device_id);
using ProfFreeCallback = std::function<int(void *dev_ptr)>;
// `default_host_shadow_register` was previously a free function; it has been
// folded into `bind_manager_memory_context()` so its malloc'd shadow can
// be registered with the manager's `malloc_shadows_` set for
// safe teardown via `clear_mappings()` / `release_all_owned()`. See
// `ProfilerBase::bind_manager_memory_context()` for the inline definition.
/**
* Registered allocations are unregistered before release; direct allocations
* carry their own registration state.
* RAII scope guard for collector `init()` rollback. On destruction (without
* `commit()`) it (1) calls `manager.release_all_owned(release_fn)` to free
* every framework-tracked device allocation and malloc shadow, and (2)
* releases any extra direct dev_ptrs the collector added via `add_direct_ptr()`
* for caller-owned allocations not transferred to the manager.
*
* Pattern:
* int Collector::init(...) {
* ...
* set_memory_context(...);
* InitRollbackGuard<Manager> guard(manager_, free_cb);
* void *dev_ptr = alloc_paired_buffer(size, &host_ptr);
* if (dev_ptr == nullptr) return PTO_RUNTIME_ERR_INTERNAL; // guard runs, frees nothing yet
* ...
* void *direct = alloc_cb(...);
* guard.add_direct_ptr(direct); // ensure it's freed on abort
* ...
* guard.commit(); // success — disarm
* initialized_ = true;
* return 0;
* }
*/
template <typename Manager>
class InitRollbackGuard {
public:
using ReleaseFn = std::function<int(void *)>;
InitRollbackGuard(
Manager &manager, ReleaseFn release_fn, ProfUnregisterCallback unregister_fn = nullptr, int device_id = -1
) :
manager_(manager),
release_fn_(std::move(release_fn)),
unregister_fn_(unregister_fn),
device_id_(device_id),
committed_(false) {}
~InitRollbackGuard() {
if (committed_) return;
manager_.release_all_owned([this](void *p, bool registered) {
for (auto &entry : direct_ptrs_) {
if (entry.ptr == p) {
entry.released = true;
registered = registered || entry.registered;
}
}
release(p, registered);
});
for (const auto &entry : direct_ptrs_) {
if (!entry.released) release(entry.ptr, entry.registered);
}
}
InitRollbackGuard(const InitRollbackGuard &) = delete;
InitRollbackGuard &operator=(const InitRollbackGuard &) = delete;
void add_direct_ptr(void *p, bool registered = false) {
if (p == nullptr) return;
for (auto &entry : direct_ptrs_) {
if (entry.ptr == p) {
entry.registered = entry.registered || registered;
return;
}
}
try {
direct_ptrs_.push_back({p, registered, false});
} catch (...) {
release(p, registered);
throw;
}
}
void commit() { committed_ = true; }
private:
struct DirectAllocation {
void *ptr;
bool registered;
bool released;
};
// The manager invokes this while holding its mapping lock. No manager
// ownership lookup or release claim is permitted from this callback.
void release(void *p, bool registered) {
if (p == nullptr) return;
(void)manager_.release_resource(p, registered, unregister_fn_, release_fn_, device_id_);
}
Manager &manager_;
ReleaseFn release_fn_;
ProfUnregisterCallback unregister_fn_;
int device_id_;
std::vector<DirectAllocation> direct_ptrs_;
bool committed_;
};
// Result of Module::resolve_entry. Carries everything the unified
// process_entry algorithm needs to (a) refill the originating pool's free
// queue and (b) hand the ready buffer off to the collector.
//
// kind — recycled-pool index in [0, Module::kBufferKinds).
// free_queue — the originating pool's SPSC queue to refill with one buffer.
// buffer_size — bytes to allocate if the recycled+done fallbacks are dry.
// info — partially-filled ReadyBufferInfo (dev_buffer_ptr, buffer_seq,
// and any module-specific index fields are set; algorithm fills
// host_buffer_ptr after a resolve_host_ptr lookup).
template <typename Module>
struct EntrySite {
int kind;
typename Module::FreeQueue *free_queue;
size_t buffer_size;
typename Module::ReadyBufferInfo info;
};
// Outcome of one free_queue top-up. `filled` is false when the recycled lane
// ran dry before the queue reached capacity, i.e. the site needs revisiting.
struct TopUpResult {
uint64_t pushed;
bool filled;
};
// Outcome of one drain-path ready entry. kRetry is the only one that leaves the
// device's consumer index where it was, so the same entry is seen again by the
// next peek; the other two have advanced it.
enum class EntryOutcome {
kDelivered, // in the host hand-off ring, on its way to the collector
kRetry, // neither acknowledged nor delivered
kDropped, // acknowledged and counted as lost; never reached a collector
/**
* The acknowledgement could not be decided, so this queue's consumer
* position is no longer known.
*
* Distinct from `kDropped` because the *queue* is now untrustworthy, not
* just this entry: the device may or may not consider the entry consumed,
* so neither serving it again nor skipping it is sound, and the per-queue
* consumed count must not advance -- a cut that completed on it would
* declare a boundary finished over an entry nobody has settled. The caller
* stops serving the queue and marks its cut unknown.
*/
kOwnershipUnknown,
};
// What a consumer-index advance established. `kUnknown` is not a failure to
// write — it is a failure to find out, which is why it may not be treated as
// either outcome: re-delivering a consumed entry and dropping an unconsumed one
// are both losses of exactly the kind this transport exists to prevent.
enum class AckOutcome {
kConsumed, // the device's head has advanced past this entry
kNotConsumed, // the device's head is unchanged; the entry can be retried
kUnknown, // neither could be established
};
/**
* What one attempt to read the producer's schema declaration established.
*
* `kFailed` is not `kUndeclared`. A transfer that did not happen says nothing
* about what the producer declared, and treating the two alike is what turns a
* momentary link failure into "this producer publishes no identity".
*/
enum class HandoffSchemaRead : int {
kFailed = -1,
kUndeclared = 0,
kDeclared = 1,
};
/**
* What this host knows about the producer's identity declaration.
*
* `kUnsettled` is the absence of a verdict, not a verdict: either no sample has
* been taken yet, or every sample so far failed. An entry may not be consumed
* in this state. Consuming one would have to guess, and the only available
* guess — "undeclared" — erases identity a current producer did supply, which
* turns a payload failure with a known owner into an unattributed one.
*
* The producer stamps its declaration before it publishes any entry, so a
* sample taken after an entry has been observed sees the stamp. That ordering
* is what makes `kUndeclared` mean a legacy producer rather than a host that
* looked too early.
*/
enum class HandoffTrust : uint8_t {
kUnsettled,
kDeclared, // the producer declared the supported schema
kUndeclared, // a post-publication read found no supported declaration
kUnreadable, // the bounded read attempts were all refused by the link
};
/**
* What a `kDropped` hand-off was, for the owner that counts the loss.
*
* `identified` separates the two reasons a buffer is retired, which need
* different answers and must never be merged into one anonymous tally:
*
* - the descriptor validated and the payload could not be read. The producer
* published who it was and how many records it committed, so this loss
* belongs to a known run and a known count.
* - the descriptor itself did not validate. Nothing in it can be trusted —
* including any run id it appears to carry — so this is a protocol failure
* with no owner, and guessing one from whichever run is executing would be
* the attribution error this whole mechanism exists to remove.
*/
template <typename Module>
struct RetiredHandoff {
bool identified{false};
typename Module::ReadyBufferInfo info{};
};
// Unified mgmt-loop algorithms parameterized on Module's data-access traits.
// Module supplies the layout (constants + types + resolve_entry +
// for_each_instance); ProfilerAlgorithms supplies the control flow that used
// to be hand-rolled per subsystem.
template <typename Module>
struct ProfilerAlgorithms {
using DataHeader = typename Module::DataHeader;
using ReadyEntry = typename Module::ReadyEntry;
using ReadyBufferInfo = typename Module::ReadyBufferInfo;
using FreeQueue = typename Module::FreeQueue;
// Optional Module hook: a module whose entry carries identity is told
// whether this host may believe it, instead of reading a shared header
// field that one drain shard may be writing. A module without the overload
// takes the `long` form and is unchanged.
template <typename M = Module>
static auto resolve(void *shm, DataHeader *header, int q, const ReadyEntry &entry, bool identity_trusted, int)
-> decltype(M::resolve_entry(shm, header, q, entry, identity_trusted)) {
return M::resolve_entry(shm, header, q, entry, identity_trusted);
}
template <typename M = Module>
static auto resolve(void *shm, DataHeader *header, int q, const ReadyEntry &entry, bool, long)
-> decltype(M::resolve_entry(shm, header, q, entry)) {
return M::resolve_entry(shm, header, q, entry);
}
// Read the entry at the head of the per-thread ready queue without
// acknowledging it. Returns false if the queue is empty (or the device wrote
// an out-of-range head/tail, which is treated as empty and reported).
//
// The device still owns the slot on return: only ack_aicpu_entry() advances
// the consumer index, and process_entry calls it once the entry's payload is
// in the host shadow. So a host-side failure between the two costs a repeat
// of this peek rather than the record.
//
// Torn-read defense: the per-tick `mirror_shm_from_device` is a single
// bulk rtMemcpy that is not atomic w.r.t. concurrent AICPU writes. AICPU
// publishes a ready entry by first writing `queues[q][tail].{buffer_ptr,
// core_index, buffer_seq}` and then bumping `queue_tails[q]`. If the
// bulk mirror happens to scan the entry slot first and the tail counter
// last, host can observe `head < tail` while the entry it's about to
// read is still pre-publish (e.g. `buffer_ptr == 0`). We refresh the
// entry with `read_range_from_device` and skip the peek if the refreshed
// entry still looks empty — try again next tick.
template <typename Mgr>
static bool
try_peek_aicpu_entry(Mgr &mgr, DataHeader *header, int q, ReadyEntry &out, bool refresh_indices = false) {
if (refresh_indices) {
if (mgr.read_range_from_device(&header->queue_heads[q], sizeof(header->queue_heads[q])) != 0 ||
mgr.read_range_from_device(&header->queue_tails[q], sizeof(header->queue_tails[q])) != 0) {
LOG_ERROR("%s: failed to refresh ready_queue indices for thread %d", Module::kSubsystemName, q);
return false;
}
rmb();
}
uint32_t head = header->queue_heads[q];
uint32_t tail = header->queue_tails[q];
if (head >= Module::kReadyQueueSize || tail >= Module::kReadyQueueSize) {
LOG_ERROR(
"%s: invalid queue indices for thread %d: head=%u tail=%u (max=%u)", Module::kSubsystemName, q, head,
tail, Module::kReadyQueueSize
);
return false;
}
if (head == tail) return false;
// Order the tail-vs-empty check before the entry read so the
// entry load cannot be speculated past it on aarch64.
rmb();
// Re-pull this single entry from device to defeat the torn-read
// race described above. If the entry's `buffer_ptr` is still 0 the
// producer hasn't finished publishing — treat the queue as empty
// for this tick.
if (mgr.read_range_from_device(&header->queues[q][head], sizeof(header->queues[q][head])) != 0) {
LOG_ERROR("%s: failed to refresh ready_queue entry for thread %d", Module::kSubsystemName, q);
return false;
}
rmb();
out = header->queues[q][head];
return out.buffer_ptr != 0;
}
// Hand the slot holding ready queue q's head entry back to the device by
// advancing the consumer index.
//
// a5: the head advance is written back to device immediately via
// `mgr.write_range_to_device(&header->queue_heads[q], ...)` so AICPU sees
// the consumer-side update without us bulk-mirroring the whole shm region
// (which would clobber AICPU-owned fields elsewhere in the shm). A failed
// write-back leaves the host-side index where the device has it, so the
// entry stays unacknowledged and the next peek sees it again.
template <typename Mgr>
static bool ack_aicpu_entry(Mgr &mgr, DataHeader *header, int q) {
return ack_aicpu_entry_checked(mgr, header, q) == AckOutcome::kConsumed;
}
/**
* Advance this queue's consumer index, and on a failed write find out
* whether it actually landed.
*
* A failing `write_range_to_device` does not prove the device never saw the
* new head: the transfer can fail after the bytes are in place. Restoring
* the host shadow and retrying on that assumption would re-deliver an entry
* the device already considers consumed. So the device's own head is read
* back and compared against the only two values a single consumer can have
* produced:
*
* old_head — the write did not land; retrying this entry is correct
* expected_head — the write did land; this entry is consumed, and the
* host shadow is brought forward to match
* anything else — this queue is not in a state a single consumer can
* explain. Neither answer is safe, so the queue is left
* where it is and the caller is told it is unknown.
*
* The two-way reading holds only because each queue has exactly one
* consumer — its own drain shard — and nothing resets the index underneath
* it. A read-back that itself fails is `kUnknown` for the same reason: it
* decides nothing.
*
* On a platform whose host and device share the region there is no transfer
* to fail, so this path is unreachable there and the check costs nothing.
*/
template <typename Mgr>
static AckOutcome ack_aicpu_entry_checked(Mgr &mgr, DataHeader *header, int q) {
const uint32_t old_head = header->queue_heads[q];
const uint32_t expected_head = (old_head + 1) % Module::kReadyQueueSize;
header->queue_heads[q] = expected_head;
wmb();
if (mgr.write_range_to_device(&header->queue_heads[q], sizeof(header->queue_heads[q])) == 0) {
return AckOutcome::kConsumed;
}
LOG_ERROR("%s: failed to advance ready_queue head for thread %d", Module::kSubsystemName, q);
if (mgr.read_range_from_device(&header->queue_heads[q], sizeof(header->queue_heads[q])) != 0) {
// Cannot be decided either way. Leave the shadow where the failed
// write left it rather than asserting a value, and say so.
LOG_ERROR(
"%s: could not read back ready_queue head for thread %d; whether the entry was consumed is unknown",
Module::kSubsystemName, q
);
// Shadow deliberately left advanced. An undecidable acknowledgement
// has two possible repairs and only one of them is safe: serving
// the entry again would deliver and free a buffer the device may
// already have released, while skipping it costs one hand-off that
// the caller counts and reports. Losing a counted buffer beats
// delivering an uncounted one twice.
return AckOutcome::kUnknown;
}
rmb();
const uint32_t device_head = header->queue_heads[q];
if (device_head == old_head) {
header->queue_heads[q] = old_head;
return AckOutcome::kNotConsumed;
}
if (device_head == expected_head) {
LOG_WARN(
"%s: ready_queue head for thread %d had already advanced; the entry is consumed despite the write "
"failure",
Module::kSubsystemName, q
);
return AckOutcome::kConsumed;
}
LOG_ERROR(
"%s: ready_queue head for thread %d is %u, neither %u nor %u; this queue has no single-consumer reading",
Module::kSubsystemName, q, device_head, old_head, expected_head
);
header->queue_heads[q] = expected_head;
return AckOutcome::kUnknown;
}
// Refill the originating pool's free_queue from this drain shard's local
// recycled pool before handing the full buffer to the collector.
//
// `short_site_out`, when non-null, receives this entry's site if the top-up
// could not fill the queue (the shard's recycled lane ran dry). The caller
// retries those sites once per sweep — see mgmt_drain_loop. Without that a
// starved lane could never recover, because this top-up is entry-driven and
// a lane with no buffer has nothing left to publish.
//
// `retries_exhausted` says this entry has held the queue head long enough
// that a still-failing copy must be retired rather than retried again; see
// ProfilerBase::kStalledDrainEntryTimeout.
//
// a5 specifics: after resolving the popped buffer's host shadow, copy
// the buffer contents from device to host before delivery. The host
// shadow seen by the collector then matches what the device wrote.
template <typename Mgr>
static EntryOutcome process_entry(
Mgr &mgr, DataHeader *header, int q, const ReadyEntry &entry, EntrySite<Module> *short_site_out,
bool retries_exhausted, RetiredHandoff<Module> *retired_out = nullptr, bool identity_trusted = false
) {
auto site_opt = resolve(mgr.shared_mem_host(), header, q, entry, identity_trusted, 0);
if (!site_opt.has_value()) {
// resolve_entry already logged which index failed to validate, and no
// retry can make it validate.
const AckOutcome bad_entry_ack = ack_aicpu_entry_checked(mgr, header, q);
if (bad_entry_ack == AckOutcome::kNotConsumed) return EntryOutcome::kRetry;
if (bad_entry_ack == AckOutcome::kUnknown) {
if (retired_out != nullptr) *retired_out = RetiredHandoff<Module>{};
return EntryOutcome::kOwnershipUnknown;
}
// The buffer pointer travelled in the same entry, so it is trustworthy
// only as far as the manager can vouch for it, and never trustworthy
// enough to publish into a device-visible free_queue for AICPU to
// dereference. A pointer inside a block this manager owns is parked in
// the host-only retired pool, which teardown releases; one the manager
// cannot map is withheld entirely, because release_pointer_for() hands
// an unmapped pointer to the free callback unchanged, and freeing a
// pointer from a corrupt entry is worse than forgetting it. The kind is
// unknown here and 0 is a bucket label only: the retired pools are
// drained by iterating every shard and kind, never selectively.
void *dev_ptr = reinterpret_cast<void *>(entry.buffer_ptr);
const bool parked =
mgr.resolve_host_ptr(dev_ptr) != nullptr && mgr.retire_unqueued_buffer(/*kind=*/0, dev_ptr, q);
LOG_ERROR(
"%s: retired an unresolvable ready entry on thread %d; its records are lost and its buffer %p is %s",
Module::kSubsystemName, q, dev_ptr, parked ? "parked until teardown" : "withheld from the pool"
);
// Left unidentified on purpose: the fields that would name a run
// are in the descriptor that just failed to validate.
if (retired_out != nullptr) *retired_out = RetiredHandoff<Module>{};
return EntryOutcome::kDropped;
}
auto &site = *site_opt;
site.info.host_buffer_ptr = mgr.resolve_host_ptr(site.info.dev_buffer_ptr);
if (site.info.host_buffer_ptr == nullptr) {
// resolve_host_ptr already logged. Mappings are established when a
// buffer is allocated, i.e. before the device can ever publish it,
// so an unmappable buffer will not become mappable on a retry.
return retire_undeliverable_entry(
mgr, header, q, site, "its device buffer is not mapped on this host", retired_out
);
}
// a5: pull buffer contents from device into the host shadow before
// the collector reads `count` and `records[]`.
if (mgr.copy_buffer_from_device(site.info.host_buffer_ptr, site.info.dev_buffer_ptr, site.buffer_size) != 0) {
LOG_ERROR(
"%s: failed to copy ready buffer from device (kind=%d, thread=%d)", Module::kSubsystemName, site.kind, q
);
if (!retries_exhausted) return EntryOutcome::kRetry;
return retire_undeliverable_entry(
mgr, header, q, site, "its device buffer could not be copied to host", retired_out
);
}
// The payload is in the host shadow, so the device's slot can go back.
// Doing this before the copy would put a failure between the
// acknowledgement and the delivery, which is exactly how a record gets
// lost without anything counting it.
// Only a decided, unconsumed acknowledgement may be retried: retrying an
// undecided one would hand this buffer to the collector twice.
const AckOutcome ack = ack_aicpu_entry_checked(mgr, header, q);
if (ack == AckOutcome::kNotConsumed) return EntryOutcome::kRetry;
if (ack == AckOutcome::kUnknown) {
LOG_ERROR(
"%s: thread %d could not settle a ready buffer's acknowledgement; the queue is stopped and its cut is "
"unknown",
Module::kSubsystemName, q
);
// Identity is known, but the buffer's ownership is not: the device
// may still hold it. It is neither delivered nor returned to a
// device-visible free queue -- the manager's host-only retired pool
// keeps it until teardown.
(void)mgr.retire_unqueued_buffer(site.kind, site.info.dev_buffer_ptr, q);
if (retired_out != nullptr) *retired_out = RetiredHandoff<Module>{true, site.info};
return EntryOutcome::kOwnershipUnknown;
}
// Drain-driven free_queue top-up. The drain shard that serves ready queue
// q is the sole runtime writer of every free_queue that q's entries
// resolve to, so this needs no coordination with the replenish thread —
// that thread only writes host-side recycled lanes at runtime.
if (!top_up_free_queue(mgr, site.kind, *site.free_queue, site.buffer_size, q).filled &&
short_site_out != nullptr) {
*short_site_out = site;
}
// Ownership stays here until the collector frees a host-ring slot;
// retiring on transient host backpressure would make the buffer
// unreachable to Derived::on_buffer_collected().
mgr.wait_push_to_ready(site.info, q);
return EntryOutcome::kDelivered;
}
// Top up every (kind, instance) free_queue to kSlotCount before worker
// threads start. At runtime each drain shard only refills its own lane.
template <typename Mgr>
static uint64_t proactive_replenish(Mgr &mgr, DataHeader *header) {
uint64_t pushed = replenish_free_queues(mgr, header);
pushed += replenish_recycled_pools(mgr, header);
return pushed;
}
// Fill every (kind, instance) free_queue from any recycled lane, allocating
// when they are dry. Startup only: `shard_index=-1` lets obtain_buffer
// allocate and lets pop_recycled_for_startup consume from every shard, both
// of which are safe only before the drain threads exist. At runtime the
// owning drain shard is the sole free_queue writer.
template <typename Mgr>
static uint64_t replenish_free_queues(Mgr &mgr, DataHeader *header) {
uint64_t pushed = 0;
refresh_replenish_metadata(mgr, header, 0);
Module::for_each_instance(mgr.shared_mem_host(), header, [&](int kind, FreeQueue *fq, size_t buf_size) {
pushed += top_up_free_queue(mgr, kind, *fq, buf_size, /*shard_index=*/-1).pushed;
});
return pushed;
}
// Keep shard-local recycled pools above the optional Module watermark.
// Used both by startup proactive_replenish and by the runtime replenish
// thread. It allocates only into host-side recycled lanes; it does not
// touch device free_queues. A small gap still allocates a slot-sized batch
// to amortize registration; a large gap is filled in one allocation.
template <typename Mgr>
static uint64_t replenish_recycled_pools(Mgr &mgr, DataHeader *header) {
std::array<size_t, Module::kBufferKinds> buffer_sizes{};
Module::for_each_instance(mgr.shared_mem_host(), header, [&](int kind, FreeQueue *, size_t buf_size) {
if (kind >= 0 && kind < Module::kBufferKinds && buffer_sizes[static_cast<size_t>(kind)] == 0) {
buffer_sizes[static_cast<size_t>(kind)] = buf_size;
}
});
const int shard_count = mgr.shard_count();
uint64_t pushed = 0;
for (int kind = 0; kind < Module::kBufferKinds; kind++) {
if (buffer_sizes[static_cast<size_t>(kind)] == 0) continue;
for (int shard = 0; shard < shard_count; shard++) {
size_t target = clamped_recycled_warm_target<Mgr>(kind, shard, shard_count);
if (target == 0) continue;
size_t current = mgr.recycled_count(kind, shard);
if (current >= target) continue;
size_t gap = target - current;
size_t batch_count = std::max(static_cast<size_t>(Module::kSlotCount), gap);
int batch = batch_count > static_cast<size_t>(std::numeric_limits<int>::max()) ?
std::numeric_limits<int>::max() :
static_cast<int>(batch_count);
pushed += mgr.allocate_recycled_batch(kind, buffer_sizes[static_cast<size_t>(kind)], batch, shard);
}
}
return pushed;
}
// Retry the free_queue top-up for sites whose recycled lane ran dry, so a
// starved lane recovers locally. Called once per drain sweep, over only the
// sites that actually came up short — empty in the normal case, so this path
// costs nothing until a lane runs dry.
//
// Runs on the owning drain shard, which is the sole runtime writer of these
// queues; `shard_index` is the ready queue the site was observed on and
// BufferPoolManager folds it onto that shard's recycled lane.
template <typename Mgr>
static bool retry_short_site(Mgr &mgr, const EntrySite<Module> &site, int shard_index) {
return top_up_free_queue(mgr, site.kind, *site.free_queue, site.buffer_size, shard_index).filled;
}
private:
static int recycled_warm_target(int kind, int shard_count) {
return profiler_module_recycled_warm_target<Module>(kind, shard_count);
}
template <typename Mgr>
static size_t clamped_recycled_warm_target(int kind, int shard, int shard_count) {
int target = recycled_warm_target(kind, shard_count);
if (target <= 0) return 0;
size_t requested = static_cast<size_t>(target);
if (requested > Mgr::kRecycledQueueCapacity) {
LOG_WARN(
"%s: recycled warm target too large for shard=%d kind=%d: target=%zu capacity=%zu; clamping",
Module::kSubsystemName, shard, kind, requested, Mgr::kRecycledQueueCapacity
);
return Mgr::kRecycledQueueCapacity;
}
return requested;
}
template <typename Mgr, typename M = Module>
static auto refresh_replenish_metadata(Mgr &mgr, DataHeader *header, int)
-> decltype(M::refresh_replenish_metadata(mgr, header), void()) {
M::refresh_replenish_metadata(mgr, header);
}
template <typename Mgr>
static void refresh_replenish_metadata(Mgr &, DataHeader *, long) {}
// Fallback used by drain-shard free_queue top-up.
template <typename Mgr>
static void *obtain_buffer(Mgr &mgr, int kind, size_t buf_size, int shard_index) {
if (shard_index < 0) {
if (void *p = mgr.pop_recycled_for_startup(kind); p != nullptr) return p;
(void)mgr.allocate_recycled_batch(kind, buf_size, Module::batch_size(kind), shard_index);
return mgr.pop_recycled_for_startup(kind);
}
void *p = mgr.pop_recycled(kind, shard_index);
if (p != nullptr) return p;
return nullptr;
}
// Retire a ready entry whose payload the host will never read: acknowledge
// the device slot so the queue keeps draining, then put the buffer back into
// the free_queue it came from so the pool does not shrink. That queue's sole
// runtime writer is this drain shard, which is why the buffer can go straight
// back rather than through a recycled lane another thread owns; when it has
// no room the manager's retired pool holds the buffer until teardown frees
// it, exactly as a failed top-up does.
template <typename Mgr>
static EntryOutcome retire_undeliverable_entry(
Mgr &mgr, DataHeader *header, int q, const EntrySite<Module> &site, const char *reason,
RetiredHandoff<Module> *retired_out = nullptr
) {
const AckOutcome ack = ack_aicpu_entry_checked(mgr, header, q);
if (ack == AckOutcome::kNotConsumed) return EntryOutcome::kRetry;
// The descriptor validated, so this loss has an owner and a count even
// though its bytes never arrived.
if (retired_out != nullptr) *retired_out = RetiredHandoff<Module>{true, site.info};
if (ack == AckOutcome::kUnknown) {
// Settled once, here, and then the queue stops. Retrying instead
// would re-record this loss if the write did not land, and lose it
// entirely if it did -- the device would simply not show the entry
// again. The buffer's ownership is unproven either way, so it is
// withheld from the free queue rather than published into it.
LOG_ERROR(
"%s: thread %d could not settle an undeliverable entry's acknowledgement; the queue is stopped",
Module::kSubsystemName, q
);
(void)mgr.retire_unqueued_buffer(site.kind, site.info.dev_buffer_ptr, q);
return EntryOutcome::kOwnershipUnknown;
}
LOG_ERROR(
"%s: retired ready buffer %p (kind=%d, thread=%d) because %s; its records are lost and are part of this "
"run's reconcile gap",
Module::kSubsystemName, site.info.dev_buffer_ptr, site.kind, q, reason
);
if (!try_push_to_free_queue(mgr, *site.free_queue, site.info.dev_buffer_ptr)) {
(void)mgr.retire_unqueued_buffer(site.kind, site.info.dev_buffer_ptr, q);
}
return EntryOutcome::kDropped;
}
// Append one buffer pointer to a per-instance free_queue if it has
// capacity. The queue owner is the drain shard for that AICPU producer;
// proactive_replenish calls this only before drain threads start.
//
// a5: write the new slot and the advanced tail back to device via
// `write_range_to_device` so AICPU sees the refill without us bulk
// mirroring (which would clobber AICPU-owned fields). The slot is
// written before the tail so AICPU never observes a tail update without
// the corresponding pointer.
template <typename Mgr>
static bool try_push_to_free_queue(Mgr &mgr, FreeQueue &fq, void *dev_ptr) {
if (mgr.read_range_from_device(&fq.head, sizeof(fq.head)) != 0) {
LOG_ERROR("%s: failed to refresh free_queue head", Module::kSubsystemName);
return false;
}
rmb();
uint32_t fq_head = fq.head;
uint32_t fq_tail = fq.tail;
if (fq_tail - fq_head >= Module::kSlotCount) {
return false;
}
uint32_t slot_idx = fq_tail % Module::kSlotCount;
uint64_t old_slot = fq.buffer_ptrs[slot_idx];
fq.buffer_ptrs[slot_idx] = reinterpret_cast<uint64_t>(dev_ptr);
wmb();
if (mgr.write_range_to_device(&fq.buffer_ptrs[slot_idx], sizeof(fq.buffer_ptrs[slot_idx])) != 0) {
fq.buffer_ptrs[slot_idx] = old_slot;
LOG_ERROR("%s: failed to publish free_queue slot", Module::kSubsystemName);
return false;
}
fq.tail = fq_tail + 1;
wmb();
if (mgr.write_range_to_device(&fq.tail, sizeof(fq.tail)) != 0) {
fq.tail = fq_tail;
fq.buffer_ptrs[slot_idx] = old_slot;
LOG_ERROR("%s: failed to publish free_queue tail", Module::kSubsystemName);
return false;
}
return true;
}
// Unknown means the device head could not be refreshed, so whether the queue
// has room is not established. It is deliberately distinct from Full: a
// caller that treated it as Full would stop retrying a lane that may still be
// starved.
enum class QueueSpace { Available, Full, Unknown };
template <typename Mgr>
static QueueSpace free_queue_space(Mgr &mgr, FreeQueue &fq) {
if (mgr.read_range_from_device(&fq.head, sizeof(fq.head)) != 0) {
LOG_ERROR("%s: failed to refresh free_queue head", Module::kSubsystemName);
return QueueSpace::Unknown;
}
rmb();
return fq.tail - fq.head < Module::kSlotCount ? QueueSpace::Available : QueueSpace::Full;
}
// Fill one (kind, instance) free_queue to kSlotCount. Startup uses any
// recycled lane and may batch-allocate; runtime uses only the drain
// shard's local recycled lane and returns when it is dry.
//
// `filled` distinguishes "the queue is at capacity" from "the recycled lane
// ran dry first", which is what tells a drain shard it must come back to this
// site. `pushed` alone cannot: a top-up that pushed nothing may equally mean
// the queue was already full.
template <typename Mgr>
static TopUpResult top_up_free_queue(Mgr &mgr, int kind, FreeQueue &fq, size_t buf_size, int shard_index = 0) {
uint64_t pushed = 0;
for (;;) {
QueueSpace space = free_queue_space(mgr, fq);