Fix: keep forked host log records intact - #1691
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughHost logging now formats complete records and writes them through one retrying ChangesSTRACE logging integrity
Estimated code review effort: 3 (Moderate) | ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@simpler_setup/tools/strace_timing.py`:
- Line 54: The regex pattern's lookahead in strace_timing.py is including
intervening content (timestamp and TIMING prefix) in the attrs capture group
instead of terminating before the next host-log record prefix. Update the
lookahead boundary at the end of the pattern to match the start of the next
complete host-log record format, ensuring attrs captures only the actual
attributes without bleeding into the next record. Additionally, in
test_strace_timing.py, replace the startswith assertion on the attrs value with
an equality check for "rank=0" so the test detects this boundary corruption by
requiring an exact match rather than a partial one.
In `@src/common/log/host_log.cpp`:
- Around line 35-66: The write_stderr function does not enforce a PIPE_BUF limit
on the record size, allowing records larger than the atomic write boundary to be
interleaved with writes from other processes. Add a check at the start of
write_stderr to cap or route records that exceed PIPE_BUF—either by truncating
the record to fit within PIPE_BUF before the write loop begins, or by routing
oversized records to an alternative sink and emitting an instrumentation error.
Ensure the record remains atomic within the PIPE_BUF boundary to prevent
interleaving with concurrent child process writes.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f7c49780-410b-47d5-abcc-800381339b36
📒 Files selected for processing (5)
simpler_setup/tools/strace_timing.pysrc/common/log/host_log.cppsrc/common/log/include/host_log.htests/ut/cpp/a5/test_host_log_off.cpptests/ut/py/test_strace_timing.py
Format each host log record dynamically and emit it through one write so forked workers sharing stderr cannot split records across process-local logger locks. Parse every STRACE marker on a physical line and cover both the logger and parser with regression tests. Fixes hw-native-sys#1690
3cfe35a to
87a75b5
Compare
ChaoZheng109
left a comment
There was a problem hiding this comment.
Reviewed the merge-base diff (639b95fbe...87a75b55e). Summary below, details in the inline comments.
What the PR does
HostLogger::emit wrote one logical record through four stdio calls under a process-local mutex, so forked chip workers sharing a captured stderr interleaved partial records; the parser then took one record per line with a greedy attrs, losing anything that followed. This PR buffers each record and emits it with a single write, and teaches the parser to read every marker on a line. Root cause and mechanism both look correctly identified.
The C++ regression test is well built: 16 forked children through a shared pipe, a start-pipe barrier so they actually contend, the reader thread started before the children are released so a full 64K pipe cannot deadlock it, and a per-line check that no record was spliced. CI is green across all 17 checks.
Against the suggestions in #1690
- (1) contiguous buffer + one atomic write — done.
- (2) harden
parse_spans— half done. Multiple markers per line are handled; torn records are still dropped silently. See the inline comment onparse_spans. - (3) validate rank/round counts and (4) stop reporting missing spans as
0.0— correctly out of scope, and worth stating explicitly in the PR description:effective_ushas zero occurrences in this repo, it lives in pypto-lib's benchmark. This is not a goal downgrade. - (5) multi-process test — done.
Missing documentation
"one record, one write, indivisible up to PIPE_BUF" is now a contract that multi-process consumers depend on, and nothing records it. Two places need it in this same commit (.claude/rules/doc-consistency.md section 4):
docs/logging.mdsection Output formats — state the guarantee, that it rests on the singlewriterather than onmutex_(which each forked process holds its own copy of), and that records longer thanPIPE_BUFare still splittable.docs/dfx/host-trace.md:25— "One line per span" is no longer true now that a physical line can carry several markers. That file is the marker-grammar reference, so someone reading it would write exactly the one-match-per-line parser this PR is fixing.
Same defect class in the sim backend (not this PR's job)
src/common/platform/sim/aicpu/device_log.cpp still emits a record via fprintf + vfprintf + fputc with no lock at all, so sim interleaves across forked chip workers and across plain AICPU sim threads — aicpu_thread_num > 1 alone is enough, no fork needed. Filed separately as #1697. Not asking for it here, but a line in the PR description would stop readers assuming the defect class is gone repo-wide. Onboard is unaffected: it buffers into char buffer[2048] and issues one dlog_* call.
Verdict
Approve with comments. Worth resolving before merge: the re.MULTILINE anchor and the torn-record counter — both small, and both close the same "records vanish silently" gap this PR exists to fix. The ostringstream performance note is optional, but there is a concrete alternative that is faster and keeps the atomicity.
| r"inv=(?P<inv>\d+)\s+hid=(?P<hid>[0-9a-fA-F]+)\s+depth=(?P<depth>\d+)\s+" | ||
| r"name=(?P<name>\S+)\s+ts=(?P<ts>\d+)\s+dur=(?P<dur>\d+)(?P<attrs>.*)" | ||
| r"name=(?P<name>\S+)\s+ts=(?P<ts>\d+)\s+dur=(?P<dur>\d+)(?P<attrs>.*?)" | ||
| rf"(?={_HOST_LOG_PREFIX}|\[STRACE\]|\r?$)" |
There was a problem hiding this comment.
Should fix. The \r?$ alternative only anchors at the end of the string — re.MULTILINE is not set. Since . never crosses \n, attrs stops at a newline, and at that position none of the three alternatives can match, so the whole record fails to match.
Effect: if a caller passes a multi-line chunk instead of one line per item, every record except the last is silently dropped. Measured against this branch:
input: two complete records, each on its own line, joined into one string
old regex (main): finds record 1
this regex: finds only record 2 <- record 1 lost
+ re.MULTILINE: finds both
In-repo this does not bite, because main() uses readlines(). But parse_spans is a module-level function consumers (pypto-lib) can call with a blob, and silently losing records is exactly the failure class this PR is fixing.
Adding re.MULTILINE to the re.compile(...) call is enough. I checked it leaves every other case unchanged: single line with and without trailing newline, CRLF, two records on one line, and the real [file:line] prefix form.
There was a problem hiding this comment.
Fixed in dffab7e — added re.MULTILINE to the re.compile(...) call.
Confirmed your reproduction on this branch: two complete records joined into one string, the first was dropped silently. With MULTILINE both are found. I re-checked the cases you listed and they are unchanged — single line with and without a trailing newline, CRLF, two records on one physical line, and the real [file:line] prefix form.
test_parse_spans_keeps_every_record_of_a_multi_line_blob now pins this.
| @@ -96,22 +101,20 @@ def by_name(self): | |||
|
|
|||
|
|
|||
| def parse_spans(lines): | |||
There was a problem hiding this comment.
Should fix. This covers the first half of suggestion (2) in #1690 — find multiple markers on a line — but not the second, detecting malformed/torn input. A record torn mid-write still yields nothing and produces no signal at all, which is precisely how #1690 manifested: effective_us=0.0 with no way to distinguish instrumentation loss from a real measurement.
Suggestion: count record heads per line and compare against the number of spans actually yielded; if the totals differ, print one warning to stderr in main() before the table. Cheap, and it converts a silent data loss into something the operator can see.
Matching the head ([STRACE] v=) rather than the bare [STRACE] literal keeps false positives away from prose that happens to mention the marker.
There was a problem hiding this comment.
Fixed in dffab7e, taking your suggestion as described.
count_record_heads(lines) counts [STRACE] v= heads independently of whether the rest of the record survived, and main() prints one stderr warning before the table when that total exceeds the spans actually parsed:
warning: 1 of 2 [STRACE] records are incomplete and are excluded from the timing below
Matching the head rather than the bare [STRACE] literal, as you suggested. test_count_record_heads_sees_a_torn_record_that_parse_spans_drops covers it.
| std::scoped_lock lock(mutex_); | ||
| fprintf(stderr, "[%s][T0x%lx][%s] %s: ", ts, tid, level_tag, func); | ||
| vfprintf(stderr, fmt, args); | ||
| std::ostringstream stream; |
There was a problem hiding this comment.
Consider (performance). The single-write fix is right, but ostringstream is an expensive way to reach it — and this PR's whole purpose is trustworthy benchmark timing.
Per record this now does: a sizing vsnprintf, a std::vector<char> heap allocation, a second vsnprintf, a std::string copy, then an ostringstream (allocating again) for the prefix. Measured on this branch (aarch64, -O2, 200k calls through HostLogger::log with stderr on /dev/null, best of 3 runs):
| variant | ns/record |
|---|---|
main (fprintf x4, non-atomic) |
~1400 |
this PR (ostringstream) |
~2100 |
stack buffer + snprintf |
~1000 |
The atomicity does not require the extra cost. Rendering the whole record (prefix + message + newline) with snprintf/vsnprintf straight into a fixed stack buffer, falling back to a heap buffer only when it does not fit, still emits exactly one write — and lands ~30% faster than main instead of ~50% slower. snprintf returns the length it needed, so "did it fit" is just comparing that against the buffer size, and the second render only happens on the rare oversized record.
Why it matters here specifically: a span's own dur is sampled before its marker is emitted, but a parent span's dur includes every child's emit cost, and PYPTO_BENCH's per-round wall clock includes all of it. At ~10 spans per decode invocation that is a few microseconds added to every measured round.
Mitigating: it is only paid when tracing is on (is_enabled short-circuits above TIMING) — but that is exactly the configuration this PR targets.
The onboard AICPU backend already uses this shape (char buffer[2048] + one dlog_* call, src/common/platform/onboard/aicpu/device_log.cpp), so it would also make the two backends consistent.
There was a problem hiding this comment.
Fixed in dffab7e — ostringstream is gone, replaced by the stack-buffer + snprintf shape you described, with a heap buffer allocated only when the record does not fit (snprintf reports the needed length, so the second render only happens on the rare oversized record).
I reproduced your benchmark independently and got the same ordering (aarch64, -O2, 200k calls through HostLogger::log, stderr on /dev/null, three runs each):
| variant | ns/record |
|---|---|
main (fprintf x4, non-atomic) |
1401–1450 |
previous commit (ostringstream) |
2428–2472 |
this commit (stack buffer + snprintf) |
926–982 |
So ~34% faster than main rather than ~74% slower, and the single write is unchanged.
Two notes on the implementation:
- The record is now formatted outside the mutex; only the
writeis under it. That was already true in intent, but theostringstreamversion built the string before taking the lock too, so this is not a new property — just no longer paying for it. kRecordStackCapacityis 2048, matching the AICPU backend you pointed at. The fork test in this PR writes ~2.1 KB records, so it exercises the heap fallback rather than skipping over it.
| fflush(stderr); | ||
|
|
||
| std::scoped_lock lock(mutex_); | ||
| // STRACE records fit within PIPE_BUF, so one write keeps each record |
There was a problem hiding this comment.
Should fix. This holds for STRACE markers but not for HostLogger's other callers, and the comment reads as an unconditional invariant.
A long LOG_ERROR — a diagnostic dump, say — pushes the record past PIPE_BUF, at which point write_stderr's partial-write loop is exactly where tearing happens, silently.
Suggest wording it as the conditional it actually is: records at or below PIPE_BUF are indivisible, longer ones can still be torn. Per .claude/rules/comments.md a comment should state a fact that holds as written.
There was a problem hiding this comment.
Fixed in dffab7e. You are right that it read as an unconditional invariant, and it is not one. New wording:
// One write per record: a record of at most PIPE_BUF bytes reaches a shared
// pipe indivisibly, so forked workers writing a captured stderr cannot
// interleave inside it. A longer record — a large LOG_ERROR dump, say — has
// no such guarantee; STRACE markers stay well below the limit.
This also answers the CodeRabbit finding on the same lines, which asked for a hard PIPE_BUF cap. I did not add one: truncating or rerouting every long record would regress large LOG_ERROR diagnostic dumps to fix a case that only arises when several forked processes share one stderr pipe and a single record exceeds PIPE_BUF. Stating the boundary honestly is the cheaper correct answer.
|
|
||
| #ifndef PLATFORM_HOST_LOG_H_ | ||
| #define PLATFORM_HOST_LOG_H_ | ||
| #pragma once |
There was a problem hiding this comment.
Consider. This guard swap is unrelated to the fix. The repo carries both styles (67 files use #pragma once, 125 use include guards), so it is not a style violation — but per .claude/rules/discipline.md section 2 every changed line should trace back to the request, and an unrelated hunk costs reviewer attention and invites conflicts.
The vlog comment update just below is the opposite case and should stay: the old text claimed the path avoided "an intermediate vsnprintf-to-buffer round-trip", which this PR makes untrue, so updating it is required doc-consistency work.
There was a problem hiding this comment.
Keeping this one, but flagging the reason rather than just overruling you — happy to revert if you still disagree.
.claude/rules/codestyle.md §12 makes #pragma once the convention and explicitly sanctions this edit:
Use
#pragma oncefor header guards, not#ifndef/#define/#endif. […] Existing files are converted on sight during routine edits; a dedicated sweep is not required.
"On sight during routine edits" is exactly this case — the PR already modifies this header for the vlog comment you noted should stay, so the file was being touched regardless. The 67/125 split you measured is the migration in progress rather than a settled two-style equilibrium.
I read discipline.md §2 as aimed at drive-by reformatting and unrelated refactors; a conversion an always-loaded rule instructs you to make while editing the file is the sanctioned exception. If you would still rather keep the diff minimal, say so and I will drop the hunk.
| from simpler_setup.tools.strace_timing import parse_spans | ||
|
|
||
|
|
||
| def _marker(pid, inv, name, attrs=""): |
There was a problem hiding this comment.
Consider. This fixture is not quite the real wire shape. LOG_TIMING prepends [%s:%d] (__FILENAME__, __LINE__) to the caller's format string (src/common/log/include/common/unified_log.h:45), so on stderr the marker is preceded by e.g. [strace.h:132] rather than sitting flush against the func: separator. I checked that the regex handles the real form correctly, so this is not a bug — but the test guards a simplified input, which weakens it as a regression barrier.
Also worth a comment near _HOST_LOG_PREFIX: its [^:\r\n]+ for the func segment relies on func being __FUNCTION__, i.e. an unqualified name. If a qualified name containing :: ever reached it, the prefix alternative would stop matching and attrs would silently absorb the next record's timestamp prefix — the [STRACE] alternative would still recover the following span, so it would degrade quietly rather than fail loudly. Worth pinning down as an assumption.
There was a problem hiding this comment.
Fixed in dffab7e — both parts.
The fixture is now built by a _record() helper that emits the real wire shape, including the [strace.h:132] segment LOG_TIMING prepends, so the marker no longer sits flush against the <func>: separator. All three parser tests share it.
And the [^:\r\n]+ assumption is now written down at _HOST_LOG_PREFIX:
# The host-log record prefix `HostLogger::emit` writes ahead of every message.
# Its func segment excludes : because `LOG_TIMING` passes `__FUNCTION__`, an
# unqualified name; a qualified name containing :: would stop this alternative
# from matching, leaving the `[STRACE]` alternative to bound the record instead.
That records both the assumption and the degradation mode you described.
Address review on hw-native-sys#1691. Host logging built each record through an ostringstream, which costs a sizing vsnprintf, a heap buffer, a string copy and a second allocation per record. Rendering prefix, message and newline straight into a stack buffer with snprintf keeps the single write that makes a record indivisible while cutting the cost: on aarch64 at -O2, 200k records with stderr on /dev/null, best of 3 — 1401 ns/record for the ostringstream form, 926 ns for this one, against 1427 ns for the pre-fix fprintf form that was not atomic at all. A heap buffer is allocated only when the record does not fit, which snprintf's return value reports directly. The emit() comment claimed records are indivisible unconditionally. The guarantee only holds at or below PIPE_BUF, so a long LOG_ERROR dump can still tear; the comment now states that boundary. The STRACE record regex ended on `\r?$` without re.MULTILINE, so `$` anchored at the end of the whole string. A caller passing a multi-line blob rather than one line per item — parse_spans is public and pypto-lib calls it — kept only the last record and dropped the rest silently. re.MULTILINE anchors every line end. A torn record still parsed as nothing at all, which is indistinguishable from a real measurement of zero. count_record_heads() counts record starts regardless of whether the rest survived, and main() warns on stderr when that total exceeds the spans actually parsed. The parser test fixture now carries the `[<file>:<line>] ` segment that LOG_TIMING prepends, so it guards the real wire shape.
|
Addressed @ChaoZheng109's review in dffab7e. Five of the six are fixed; one I am pushing back on with a rule citation, in that thread.
Validation on an aarch64 box: 80/80 non-hardware C++ tests pass, the forked-pipe test passes 10/10 standalone, the 3 parser tests pass, and pre-commit is clean on all touched files. |
Summary
write, preventing forked chip workers from interleaving STRACE records on shared stderrattrsconsume later recordsTesting
test_host_log_offfails onmainwith two child records merged onto one physical linectest -R '^test_host_log_off$': 8/8 passedpytest tests/ut/py/test_strace_timing.py -q: 1 passedctest -LE requires_hardware: 79/79 passedFixes #1690